Data Apps

Data apps are React dashboards that Definite builds into a single self-contained HTML file and serves from the on-prem deployment at /apps/<slug>. They are useful when you want a richer, purpose-built analytical app than a saved SQL query or table preview.

The normal lifecycle is:

scaffold source -> edit app.json and React -> npm run build -> upload -> open /apps/<slug>

The compiled HTML, manifest, and editable source snapshot are stored in Postgres. The source snapshot is required for new uploads so a human, Fi, or an MCP client can fetch the app later, edit it, rebuild it against the current runtime, and restore prior versions when needed.

Quick start

Create a starter project, replace the sample SQL and React, build it, then upload it:

definite run app scaffold revenue-explorer
cd revenue-explorer

# Edit app.json and src/App.tsx.
npm run build
definite run app upload revenue-explorer . --validate

Open the app at:

https://<your-deployment>/apps/revenue-explorer

Useful lifecycle commands:

definite run app list
definite run app get revenue-explorer
definite run app get revenue-explorer --source
definite run app rebuild revenue-explorer --validate
definite run app delete revenue-explorer

Uploads are create-only by default. If the slug already exists, inspect it first with definite run app get <slug> and use --force only when you mean to replace that app. Replacements keep the prior HTML and source in the app's version history.

Source fetched with app get --source includes a hidden local revision marker. An update sends that revision back, so a checkout cannot silently overwrite a newer edit. If the upload returns data_app_source_conflict, fetch the app into a new directory and reapply your intended change to the current source. Do not retry the stale checkout.

--validate checks each manifest resource before publishing the candidate. A failed resource check leaves the current app unchanged. This validation does not execute browser-side useSqlQuery, mount React, or initialize charts, so a successful response still reports verification.render_verified=false. Open the live app and confirm the modified views separately.

Manifest

Every app has an app.json manifest. On-prem manifests use version: 2 and SQL-backed resources:

{
  "version": 2,
  "name": "Revenue Explorer",
  "entry": "src/main.tsx",
  "resources": {
    "orders": {
      "kind": "dataset",
      "source": {
        "type": "sql",
        "sql": "SELECT * FROM marts.orders"
      }
    }
  }
}

Supported resource shapes:

FieldMeaning
kinddataset or json
source.typeMust be sql
source.sqlSQL run by the API against the lakehouse

SQL should reference on-prem lakehouse objects directly, such as marts.orders. LAKE.SCHEMA. prefixes are not valid in on-prem manifests.

Runtime data flow

The app runs in the browser, but the browser never receives lakehouse credentials. The bundled runtime calls:

POST /api/v1/data-apps/<slug>/query

with a named resource_key. The API checks the caller's app permission, runs the resource SQL, and returns Arrow IPC bytes. The browser-side runtime loads those bytes into DuckDB-WASM, so components can use useDataset and useSqlQuery for local filtering, grouping, and chart preparation.

Do not load an export-sized or hidden-tab resource at mount. Keep the hook unconditional, but gate the request until the data is actually needed:

const detail = useDataset("detail_rows", {
  enabled: activeTab === "detail",
});

enabled: false is idle rather than loading and performs no API request.

For large source tables, push the current structured filters into the server query instead of fetching the unfiltered table and filtering only in DuckDB-WASM:

const orders = useDataset("orders", {
  enabled: Boolean(tenantId),
  filters: tenantId
    ? [{ column: "tenant_id", operator: "equals", value: tenantId }]
    : [],
});

Filter operators are equals, not_equals, in, gte, and lte. Range operators are available for number columns and strict ISO date values (YYYY-MM-DD). Date columns use calendar-day semantics: the projected value is cast to DATE, so an lte bound includes the full end date even when the resource returns timestamps. The column and value type are checked against the resource's filterable declaration, and every predicate is compiled server-side before DuckDB materializes the result.

Home and Apps cards use a preview query route backed by the app's last successful resource snapshots. Opening the full app runs the live resource queries.

Resource result limits

Live and embedded data-app resources use the workspace client-side query limits. The defaults are 200,000 rows and 100 MiB of Arrow IPC. An operator can lower either value in Settings → General → Client-side query limits; the data-app byte limit always has a hard 100 MiB ceiling even if another query surface is configured higher.

The API consumes DuckDB record batches incrementally and stops before accepting an Arrow file larger than the byte limit. A result over either limit returns HTTP 413 with a structured data_app_query_result_too_large detail containing the limit kind, configured limit, observed size, row count, and accepted serialized bytes. Narrow the resource SQL or move an export-sized result to an on-demand export flow instead of loading it as a dashboard dataset.

Cold resource queries also have bounded server-side admission. Identical concurrent cache misses share one materialization, the 60-second server cache is bounded by both entries and bytes, and completed Arrow responses retain their admission reservation until the API has handed the body to the client. Admins can inspect current reservations, cache bytes, cumulative delivered response bytes, and row/byte cap rejections at GET /api/v1/admin/data-apps/materialization-metrics.

On-demand CSV exports

Use the queued export path when a user needs more detail than should be loaded into the browser:

import { exportResource } from "@definite/runtime";

const result = await exportResource("orders", {
  filters: [
    { column: "tenant_id", operator: "equals", value: tenantId },
    { column: "created_on", operator: "gte", value: dateRange.from },
    { column: "created_on", operator: "lte", value: dateRange.to },
  ],
});
window.location.assign(result.downloadUrl);

exportResource enqueues the resource query on the job runner's backfill lane, polls it, and returns a short-lived signed object-storage URL. DuckDB writes the CSV directly to object storage; the API never holds the result rows. The same filterable and required_filters rules as interactive resource queries are enforced before enqueue.

Exports are capped at 500,000 rows and a 1 GiB CSV file. If the row cap is reached, result.truncated is true. A file over the byte cap is deleted and the export fails. This API is available to signed-in in-product apps; preview cards and external embeds cannot start exports.

Cache TTL

Resource results are cached in the browser's IndexedDB for 24 hours by default. The cache is partitioned by resource definition, serving surface, credential, and effective filters. It retains at most 64 entries / 64 MiB and does not persist any one entry over 32 MiB; oldest entries are evicted first. Tune the TTL in app.json only when the app needs fresher or longer-lived client-side data:

{
  "version": 2,
  "name": "Hourly Revenue",
  "cache_ttl_hours": 1,
  "resources": {
    "orders": {
      "kind": "dataset",
      "cache_ttl_hours": 0.25,
      "source": {
        "type": "sql",
        "sql": "SELECT * FROM marts.orders"
      }
    }
  }
}

Top-level cache_ttl_hours sets the default for every resource. A per-resource value overrides it for that resource. Values must be positive numbers and can be fractional.

This is the in-browser cache. The external embed path also has server-side backpressure and cache behavior, covered in Data app embedding.

Compute profiles

For heavier dashboard queries, add a top-level compute_profile:

{
  "version": 2,
  "name": "Heavy Dashboard",
  "compute_profile": "large",
  "resources": {
    "orders": {
      "kind": "dataset",
      "source": {
        "type": "sql",
        "sql": "SELECT * FROM marts.orders"
      }
    }
  }
}

The profile name must exist in the deployment's compute_profiles registry. Non-default profiles route SQL resources through the Fi sandbox burst path, so interactive dashboards may need a warm pool to avoid first-load latency. See Compute Profiles.

Embedding

Inside Definite, users open apps from Home, Apps, or /apps/<slug>. To put the same app in another website, create an external embed. Embeds use revocable emb_ credentials, optional short-lived emv_ view tokens, allowed-host framing rules, and optional row/column scoping.

See Data app embedding for iframe setup, token minting, scoped filters, and capacity notes.

Each resource that accepts runtime or embed row filters must declare the filterable columns:

{
  "resources": {
    "orders": {
      "kind": "dataset",
      "source": {
        "type": "sql",
        "sql": "SELECT * FROM marts.orders"
      },
      "filterable": [
        { "column": "tenant_id", "type": "number" },
        { "column": "region", "type": "string" },
        { "column": "created_on", "type": "date" }
      ]
    }
  }
}

Scoped embed queries fail closed when a resource does not declare a required filterable column.

For resources that must never execute unfiltered, add required_filters. This is enforced for authenticated app queries, external embeds, and exports:

{
  "resources": {
    "orders": {
      "kind": "dataset",
      "source": {
        "type": "sql",
        "sql": "SELECT * FROM marts.orders"
      },
      "filterable": [
        { "column": "tenant_id", "type": "number" },
        { "column": "region", "type": "string" }
      ],
      "required_filters": ["tenant_id"]
    }
  }
}

Every required column must also be declared in filterable. A missing required column returns a structured HTTP 400 with code data_app_required_filter_missing. The camel-case cloud field requiredFilters is a different contract and is rejected during on-prem build and upload; migrate it explicitly to filterable plus required_filters.

Source, rebuilds, and versions

Every current upload includes editable source files. You can download them:

definite run app get revenue-explorer --source

After editing, rebuild locally with npm run build and upload with --force, or ask Definite to rebuild the stored source against the deployment's current data-app runtime:

definite run app rebuild revenue-explorer --validate

The app detail page and API expose runtime metadata so stale apps can be found after a runtime/template change. Admins can also bulk rebuild stored-source apps through the admin API.

Permissions

Creating a data app requires an editor or admin session. Viewing the app, loading its HTML, and running its resources require view access to that app. Replacing, renaming, rebuilding, changing its compute profile, or deleting it requires edit access to the app.

New apps are initialized with the deployment's default content visibility, then use the same content sharing model as other Definite objects.

Authoring with Fi or MCP

Fi and external MCP clients can build normal data apps. The MCP server exposes scaffold_data_app, save_data_app, validate_data_app, query_data_app_resource, list_data_apps, and get_data_app. Apps created this way use the same source snapshot, version history, permissions, and runtime as CLI-created apps.

See MCP Server for the tool flow.

Troubleshooting

SymptomWhat to check
Upload returns a slug conflictUse definite run app get <slug> to inspect the existing app, then pick a new slug or upload with --force only for an intentional replacement.
Upload says source_files is requiredUpload a project directory, not just a loose index.html; the directory must include editable source such as app.json and src/.
Upload returns data_app_source_conflictAnother editor published after this source was fetched. Fetch into a new directory and reapply the intended edit; do not retry the stale checkout.
App opens but a chart errorsResource validation does not execute browser-side SQL or charts. Open the app, inspect the surfaced runtime error, and fix the named component or useSqlQuery; use --validate separately for manifest-resource SQL.
App still shows old dataCheck cache_ttl_hours, then reload after the browser cache expires or upload a manifest with a shorter TTL.
App is marked staleRebuild it from stored source so it picks up the current runtime and mirrored library URLs.
Embedded app fails with scoped filtersMake sure every queried resource declares every filtered column in filterable.
Resource query returns HTTP 413The resource exceeded the workspace row limit or the 100 MiB data-app Arrow ceiling. Narrow the SQL; do not eagerly load export-sized data into a dashboard resource.