> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lightdash.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Building data apps locally

> Download a data app's source, edit it in your own editor, preview it against real data, and publish new versions with the Lightdash CLI.

<Info>
  **Enterprise only.** Building data apps locally requires data apps (including the build sandbox) to be enabled on your Lightdash instance. See [Self-hosting data apps](/guides/data-apps/self-hosting) if you run your own instance.
</Info>

Data apps are usually built by prompting the agent in the Lightdash UI, but every app is ultimately a small React project — and you can work on that project directly. The CLI lets you:

* **Download** an app's source, check it into git, and review changes in pull requests.
* **Edit** the app in your own editor, or point your own AI coding agent at it.
* **Preview** the app locally against real Lightdash data, with your permissions.
* **Upload** your changes — the server rebuilds the app and publishes a new version.
* **Create** brand-new apps from scratch, without going through the UI at all.
* **Move** apps between projects and instances.

## Prerequisites

* The [Lightdash CLI](/references/lightdash-cli) installed and logged in (`lightdash login <your-instance-url>`), version **1.55.0 or later** (the first release with slug-based app identity and the `apps create` / `apps preview` commands).
* Data apps enabled on the instance (enterprise).
* An **Editor** role or above on the project — uploading requires the `create:DataApp` scope.
* **Node.js 20+** (which includes npm) for local builds, previews, and `lightdash apps create`.

## Quickstart: download, edit, upload

This ten-minute loop is the core workflow. It assumes you already have an app in your project (built in the UI); to start from nothing instead, skip ahead to [Creating a new app locally](#creating-a-new-app-locally).

### 1. Download the app

Apps are identified by their **slug** — a project-scoped id derived from the app's name. You can pass a slug, a UUID, or simply paste the app's URL:

```bash theme={null}
lightdash download --apps-only --apps revenue-explorer
```

`--apps-only` gives you a clean, app-only checkout — no charts, dashboards, or space files. The app lands in `lightdash/apps/revenue-explorer/`.

### 2. Look around

Every downloaded app is a locally buildable Vite + React project:

| Path                                                       | What it is                                                                                                                                                                                                                   | Uploaded?                               |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `src/`                                                     | The app's source. **This is the only directory the CLI uploads — edit here.**                                                                                                                                                | Yes                                     |
| `lightdash-app.yml`                                        | The manifest: `slug` (the app's identity), `name`, `description`, linked external connections, and bookkeeping fields.                                                                                                       | Yes (as metadata)                       |
| `package.json`, `pnpm-lock.yaml`                           | The dependency set. Template-only apps ship the standard set; see [Custom dependencies](#experimental-custom-dependencies).                                                                                                  | Only when deps differ from the template |
| `vite.config.js`, `tsconfig.json`, `index.html`, …         | Local build scaffolding. Editing these has no effect on the deployed app — the server builds against its own trusted copies.                                                                                                 | No                                      |
| `README.md`, `AGENTS.md`, `.claude/skills/`, `references/` | Docs and agent skills for humans and AI assistants working on the app.                                                                                                                                                       | No                                      |
| `.lightdash/context/`                                      | A read-only, point-in-time snapshot of the source project: `semantic-layer.yml` (the tables, dimensions, and metrics the app can query), `parameters.yml`, `prompt-history.md`, and theme assets. Re-download to refresh it. | No                                      |

The built output (`dist/`) is intentionally never downloaded or uploaded — the server always rebuilds from source.

### 3. Make a change

Edit anything under `src/`. If you're using an AI coding agent, point it at the app folder — the bundled `AGENTS.md` and `.claude/skills/` teach it the ground rules, and `.lightdash/context/semantic-layer.yml` tells it which fields exist.

To sanity-check that the source still compiles:

```bash theme={null}
npm install
npm run build
```

<Info>
  A local build failure is a **warning, not a blocker**. The server's build is authoritative and runs in a controlled sandbox — if you're confident in your change (or your local environment is the problem), you can upload anyway and let the server build decide.
</Info>

### 4. Preview it against real data (optional)

```bash theme={null}
lightdash apps preview
```

Run this from inside the app folder (or pass the folder as an argument). It starts the Vite dev server with a local proxy that lets the app run real queries against your Lightdash instance — authenticated as **you**, with your permissions and user attributes. Open the URL Vite prints (usually `http://localhost:5173`) and edit `src/` with instant hot reload.

See [Local preview](#local-preview) for details and limitations.

### 5. Upload

```bash theme={null}
lightdash upload --apps revenue-explorer
```

The CLI posts the `src/` tree and manifest, then returns immediately:

```
Uploaded "Revenue explorer" — updated v4 (…). Building in the background;
the app will show "building" until the server finishes.
```

The server rebuilds the app in its sandbox and publishes the new version once the build succeeds. If the build fails, the error shows up in the app's version history in the UI.

That's the whole loop: edit `src/`, upload, repeat.

## Creating a new app locally

You don't need the UI to start an app. `lightdash apps create` scaffolds a complete, buildable app folder:

```bash theme={null}
lightdash apps create "Revenue explorer"
# creates ./lightdash/apps/revenue-explorer
```

The command:

1. Checks the slug is available in the target project (derived from the name; override with `--slug`).
2. Asks for confirmation, then installs the template's npm packages and generates the shadcn UI components locally (this is why npm is required). Pass `-y` to skip the prompts in scripts.
3. Writes a ready-to-edit app folder with the same layout as a downloaded app, including the `.lightdash/context/` snapshot of your project's semantic layer.

The app doesn't exist on the server yet — it's created on your first upload:

```bash theme={null}
cd lightdash/apps/revenue-explorer
npm run build          # optional local check
lightdash upload --apps revenue-explorer
```

Useful flags: `--description <text>`, `--slug <slug>`, `--project <uuid>` (defaults to your selected project), `-p, --path <path>` (content root, default `./lightdash`).

## Understanding identity: slugs

The `slug` field in `lightdash-app.yml` is the app's identity, and it's scoped to a project — the same slug can exist in different projects. On upload, the server upserts by slug in the **target** project:

* Slug exists there → a new version is appended to that app.
* Slug doesn't exist there → a new app is created with that slug.

This makes app folders portable: the same folder can update the app in project A and create/update a sibling in project B, with no manifest editing in between. The folder name matches the slug by convention, but the manifest is what counts.

Renaming an app is a metadata edit: change `name` (or `description`) in the manifest and upload. The slug — and therefore the app's identity and URLs — doesn't change.

<Info>
  Manifests downloaded from current servers also contain `appUuid` and `projectUuid`. These are legacy fields kept for compatibility with older CLIs — the slug is what identifies the app. Older bundles without a `slug` still upload correctly via uuid matching; re-download them to upgrade.
</Info>

### Copying an app instead of updating it

Pass `--create-new` to force a fresh app (with a newly generated slug) instead of updating the one the manifest points at — useful for forking:

```bash theme={null}
lightdash upload --apps revenue-explorer --create-new
```

### Moving apps between projects and instances

Between projects on one instance, target the other project:

```bash theme={null}
lightdash upload --apps revenue-explorer --project <target-project-uuid>
```

Between instances, log in to the destination first (or set `LIGHTDASH_URL` and `LIGHTDASH_API_KEY`):

```bash theme={null}
lightdash login https://destination.lightdash.cloud
lightdash upload --apps revenue-explorer --project <target-project-uuid>
```

The destination rebuilds the source in its own sandbox — no built artifacts move between instances.

<Info>
  **Semantic layer coupling.** A moved app queries the target project's fields **by name**. If a referenced field doesn't exist there, the upload and build still succeed, but the app surfaces query errors at runtime. Check the app in the destination after moving it.
</Info>

## Versioning

Every accepted upload creates a new **version** of the app, alongside versions created by prompting in the UI. Versions move through `pending → building → ready` (or `error`), and viewers always see the latest ready version. The full timeline — including who created each version and any build errors — is in the app's version history in the UI, where you can also restore an earlier version.

Two behaviors keep the version history clean:

* **Unchanged uploads are skipped.** If the source, dependencies, and viz schema are byte-for-byte identical to the latest version, the server skips the rebuild and reports `matches v<n> — skipped, no rebuild`. Name/description edits still apply. Pass `--force` to rebuild anyway. (A latest version in `error` state is never skipped — re-uploading retries the build.)
* **Build slots are limited.** Each project runs at most 5 app builds at once. If you upload a large batch, the CLI automatically waits for a free slot (up to 10 minutes of consecutive waiting) instead of failing — you'll see `Project build limit reached — waiting for builds to finish…`.

For CI, note that `lightdash upload` exits non-zero if any app upload fails, and prints a per-run tally: `data apps created / updated / unchanged / failed / skipped`.

We recommend treating **git as the source of truth** for locally-managed apps: commit the app folder, make changes via PRs, and upload from CI or after merge. Re-download after someone iterates on the app in the UI, so your local copy doesn't silently drift behind.

## Data apps on dashboards

Dashboards reference data app tiles portably, by `appSlug` in the dashboard YAML. The CLI keeps the two in sync:

* `lightdash download` (with dashboards) also downloads any data apps the dashboards reference, into `lightdash/apps/`.
* `lightdash upload` (with dashboards) uploads referenced app folders **before** the dashboards — even without any `--apps` flag — so the tiles resolve. Unchanged apps that already exist in the target are skipped.

If a dashboard tile references an app that has no local folder and doesn't exist in the target project, the tile is skipped with a warning rather than failing the dashboard upload.

## Local preview

`lightdash apps preview` runs a downloaded (or locally created) app on your machine against a real Lightdash instance:

```bash theme={null}
cd lightdash/apps/revenue-explorer
npm install            # preview does not auto-install
lightdash apps preview
```

Open the URL the Vite dev server prints (usually `http://localhost:5173`). Edits to `src/` hot-reload instantly, and every query the app runs goes to your real project.

**How authentication works.** Your API key never reaches the app or the browser. The CLI starts a loopback proxy that holds your credential; the dev server forwards the app's API calls through it. The proxy only allows the same narrow set of SDK routes a deployed app can reach (running queries, polling results, downloads), pins requests to one project, and strips cookies in both directions. A bare `npm run dev` without the CLI has no data access at all.

Things to know:

* **You see your own data.** Preview runs with your permissions and user attributes. Viewers of the deployed app may see different results.
* **Pick the project** with `--project <uuid>` (defaults to the manifest's project) and override credentials with `--url` / `--token` (prefer `lightdash login` or `LIGHTDASH_API_KEY` over `--token`, which leaks into shell history).
* **Not everything is emulated.** External connection fetches, Google Sheets export, the network inspector, data-app-viz field context, and URL state integration only work in the deployed app.
* Apps downloaded with an older CLI may predate the preview proxy — re-download the app if preview tells you the scaffolding is too old.

## External connections

If an app fetches from third-party APIs via [external connections](/guides/data-apps/external-connections), the manifest records the links:

```yaml theme={null}
externalConnections:
  - alias: stripe            # the name the app code uses
    connectionSlug: stripe-api  # the connection registered in the project
```

On upload, each `connectionSlug` is resolved in the **target** project:

* If the field is present, the app's links are reconciled to match it exactly (an empty list `[]` unlinks everything).
* If the field is absent, existing links are left untouched.
* A slug that doesn't exist in the target is skipped with a warning — the app still uploads, but that fetch will fail at runtime until the connection is linked.

The connection definitions themselves are a separate as-code resource. To move an app *and* its connections to a new project:

```bash theme={null}
# In the source project
lightdash download --include-external-connections --apps-only --apps revenue-explorer

# In the target project (secrets never travel in files — provide them via env)
export LIGHTDASH_EXTERNAL_CONNECTION_SECRET_STRIPE_API=sk_live_…
lightdash upload --apps revenue-explorer --project <target-project-uuid>
```

Connection files live in `lightdash/external-connections/<slug>.yml` and upload before apps in the same run, so the links resolve. Managing connections requires the admin-only `manage:ExternalConnection` scope.

## Advanced: how the build works

You never upload compiled output — the server builds every version from source in an isolated sandbox:

1. `lightdash upload` posts the manifest and the `src/` tree. The server validates the bundle, stores the source, and creates a new version in `pending`.
2. A build job restores the source into a sandbox containing the trusted app template. Any `scripts` in an uploaded `package.json` are replaced with the template's — the build command is never uploader-controlled.
3. The sandbox runs the standard Vite production build. There's no AI involved and no auto-fixing: the build either succeeds or fails loudly.
4. On success the compiled app is published and the version becomes `ready`. On failure the version is marked `error` with the compiler output attached in the version history.

Because the server rebuild is authoritative, your local `npm run build` is only a convenience pre-check — a local failure (from a Node version mismatch, say) doesn't predict a server failure, which is why the CLI treats it as a warning.

Limits: 5 concurrent builds per project (the CLI waits for a slot automatically), 60 direct custom dependencies, 2 MB lockfile.

## Experimental: custom dependencies

<Warning>
  **Experimental and off by default.** Custom dependencies must be enabled for your organization — contact Lightdash support (or your admin, on self-hosted instances) to turn on the feature flag. Uploading an app that declares custom dependencies additionally requires the `manage:DataAppDependency` permission, granted only to admins by default.
</Warning>

By default, apps build against a fixed, pre-installed library set — edits to `package.json` are ignored. With custom dependencies enabled, you can extend that set from the app folder:

```bash theme={null}
cd lightdash/apps/revenue-explorer
pnpm add deck.gl          # updates package.json AND pnpm-lock.yaml
lightdash upload --apps revenue-explorer
```

Both files matter: the dependency set is `package.json` plus a matching `pnpm-lock.yaml` (the server installs with pnpm, so an npm lockfile won't do). On upload, the CLI shows what you're adding and asks for confirmation:

```
"revenue-explorer" declares custom dependencies that will be installed in the build sandbox:
  + deck.gl@^9.3.5 (not in default template)
Upload "revenue-explorer" with custom dependencies?
```

**Rules the CLI and server enforce:**

* Registry semver specs only (`^9.3.5`, `~1.2.0`, ranges). No `git:`/`file:`/URL specs and no dist-tags like `latest`.
* At most 60 direct dependencies; lockfile ≤ 2 MB and consistent with `package.json`.
* Every version is screened against the OSV malicious-package feed at upload; known-bad versions are rejected outright.

**Security model:** the sandbox installs with `--frozen-lockfile --ignore-scripts`, with network egress limited to the npm registry, and the build never sees any credentials. Install scripts also never run on your machine — the scaffolded `.npmrc` sets `ignore-scripts=true`; keep it. Self-hosted instances can additionally require a minimum package release age (`LIGHTDASH_APP_DEPENDENCY_MIN_RELEASE_AGE_DAYS`) or point installs at an internal mirror (`LIGHTDASH_APP_DEPENDENCY_REGISTRY_HOSTS`).

**Uploads are the only way to change dependencies.** When someone iterates on the app in the UI, the AI builder can edit `src/` but the stored dependency set is restored as-is — its `package.json` edits are ignored. Dependency changes on a version are visible in the version history.

## CLI reference

See [`lightdash apps create`](/references/lightdash-cli#lightdash-apps-create), [`lightdash apps preview`](/references/lightdash-cli#lightdash-apps-preview), and the data app flags on [`lightdash download`](/references/lightdash-cli#lightdash-download) and [`lightdash upload`](/references/lightdash-cli#lightdash-upload) for the full flag lists.
