- name
- metabase-data-app-setup
- description
- Scaffold a new Metabase data-app into the connected remote-sync repository's `data_apps/<app>/` directory from the `data-app-template`. Use when the user asks to start, create, scaffold, or set up a data-app from scratch.
# Create a Metabase Data App
A Metabase **data-app** is a single JS bundle that the host loads inside a Near Membrane sandbox and renders inside its own React tree. The scaffold is a Vite + React + TypeScript project: source under `src/`, a dev server that previews the app against a real Metabase **through the same Near Membrane sandbox + distortion rules Metabase uses in production** — so `npm run dev` behaves like production, including for third-party libraries the app bundles — and `npm run build` producing a single `dist/index.js`. (Because the sandbox runs a built bundle, a change rebuilds it and does a *soft reload* — re-evaluates the bundle in the sandbox and remounts the app, keeping auth/SDK loaded — rather than hot-swapping modules; component state resets, but there's no full browser refresh.) The dev preview also shows a corner **⚠ Diagnostics** toolbar that captures runtime errors — including the sandbox's otherwise-opaque blocked-API messages — so failures surface instead of being swallowed. The same data is served as JSON at `http://localhost:5174/__data-app/diagnostics`, which is how *you* read it (see "Reading the diagnostics feed" below) — you have a shell, not a browser, and these failures are invisible from the terminal otherwise.
**Data apps are served from Git, not uploaded.** A single repository is connected to Metabase via remote-sync (Admin → Settings → Remote sync). Each app lives in its own directory `data_apps/<app>/` inside that repo — its source, a `data_app.yaml` (name/path), and the committed built bundle at the `path` its `data_app.yaml` declares (`dist/index.js` by default). On each remote-sync import Metabase materializes one app per directory and serves it at `/apps/<slug>` url, where the slug **is** the directory's name. So this skill always scaffolds **into the connected repo's `data_apps/<app>/` directory**, never as a standalone project.
**The scaffold ships inside this skill at `./template/`** — a Vite + React + TypeScript project that was installed alongside the skill. Step 3 just copies it into the app directory; the skill then guides you through the customization + first-app-content steps — it never generates project files from scratch. If you find yourself writing `package.json`, `vite.config.ts`, `tsconfig.json`, or `src/index.tsx` by hand, stop — copy the template instead.
## When to invoke this skill
- "scaffold a new data app" / "create a Metabase data app" / "set up a data-app project"
- "I want to build a data app" / any vague intent to author a data app
- Starting a fresh agent task that will produce a data-app bundle.
- Do **not** use this skill for an existing data-app project when the task is to
build screens, use Metabase data, generate or refresh schema files, wire saved
questions / tables / metrics / actions, add filters, or author data hooks.
Treat those as existing-data-app editing tasks and use the agent's normal
skill-discovery flow from the user's wording.
## Step 1 — Locate the remote-sync repository
Data apps live inside the Git repository connected to Metabase via remote-sync. Find it before scaffolding:
- Ask: **"Do you already have a Git repository connected to this Metabase via remote-sync?"**
- **Yes** → ask for its local path.
- **No** → ask the user to **create one** (a plain Git repo they will connect under Admin → Settings → Remote sync) and share its path. The skill does **not** create or connect the repo — the user owns that.
- Verify the path exists and is a Git working tree (it has a `.git`). This repo is the working directory for every step below.
## Step 2 — Name the app and create its directory
1. Settle on the app's **directory name** before scaffolding — it is used verbatim as the slug (the `/apps/<slug>` URL), so it **must be dash-cased**: lowercase letters, numbers, and single dashes (`[a-z0-9]+(?:-[a-z0-9]+)*`), e.g. `sales-overview`. Anything else (uppercase, spaces, underscores) is rejected on sync. If the purpose isn't clear yet, ask a one-line "what's this app for?" and propose a name; confirm it.
2. Ensure `<repo>/data_apps/` exists; create it if missing.
3. Create `<repo>/data_apps/<slug>/`. **If it already exists**, treat it as an existing project (see below) — never overwrite without confirmation.
### Detecting an existing app
If `<repo>/data_apps/<slug>/` already holds a project, verify it matches the current `data-app-template`. Check **all** of:
1. `vite.config.ts` is a one-liner: `export default dataAppConfig()`
(from `@metabase/embedding-sdk-react/data-app-dev/config`). There is **no**
local `config/` directory: the whole bundle contract (externals/globals, the
dev sandbox entry, CSS/SVG handling) lives inside that SDK config, not the
scaffold. `dataAppConfig` takes only a curated set of overrides (currently just
`port`); the contract plugin is always applied and can't be overridden.
2. `src/index.tsx` default-exports a `DataAppFactory` (type from
`@metabase/embedding-sdk-react/data-app`) returning `{ component, providerProps? }`
(no args).
**All checks pass** → template-shaped. Ask: "Extend this app, or scaffold a new one under a different slug?" If extend → skip the copy step, edit `src/`. If new → pick a different slug and restart at Step 2.
**Any check fails** → not template-shaped (older scaffold or drift). **Stop.** Tell the user the structure differs from the current template, extending it risks breaking the bundle contract, and ask whether to (1) migrate it, (2) scaffold fresh under a new slug and port the code over, or (3) proceed anyway at their risk. Wait for the answer.
Never overwrite existing files without explicit confirmation.
## Step 3 — Copy the template into the app directory
The template ships **inside this skill** at `./template/` (installed alongside the skill via `skills add metabase/metabase/skills#release-x.<major>.x`). Copy it into the app directory:
```bash
APP_DIR="<repo>/data_apps/<slug>"
# `<skill-dir>` = the directory this SKILL.md was loaded from
# (e.g. `.claude/skills/metabase-data-app-setup`); the template is its `template/` subfolder.
cp -R "<skill-dir>/template/." "$APP_DIR/"
```
A data app is a *subdirectory* of the remote-sync repo, not its own repository — so this is a plain copy, never a nested `git clone` / `git init`. Everything below runs **inside `$APP_DIR`**.
## Step 4 — Customize
Once the template is in `<repo>/data_apps/<slug>/` (run everything below from that directory):
1. Edit `package.json` `name` to match the slug.
2. Pin `@metabase/embedding-sdk-react` to the published data-apps tag (the template ships with `*`):
```bash
npm install @metabase/embedding-sdk-react@64-alpha
```
This resolves to the current internal-testing SDK build with the `@metabase/embedding-sdk-react/data-app` entrypoint (the app's APIs) and the `@metabase/embedding-sdk-react/data-app-dev/config` entrypoint `vite.config.ts` uses (the dev/build preset, which serves the sandbox entry). Do not use `latest` or `64-stable` as data apps are not yet released.
3. **Ensure the repo-root `.gitignore` ignores `.env.local`** — do this *before* creating any credentials file so the secret can never be committed. Create the `.gitignore` if the repo doesn't have one, then add the entry if it's missing:
```bash
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$ROOT" ]; then
echo "MISSING (run this from inside the connected git repo)"
else
GITIGNORE="$ROOT/.gitignore"
# Create the repo-root .gitignore if absent, then ensure `.env.local` is
# ignored so the credentials file (next step) can never be committed.
[ -f "$GITIGNORE" ] || : > "$GITIGNORE"
grep -qxF ".env.local" "$GITIGNORE" || echo ".env.local" >> "$GITIGNORE"
fi
```
4. Set up the Metabase credentials at the **repository root** — `<repo>/.env.local` (usually two levels up from the app dir), **not** the app dir. One `.env.local` there serves every data app in the repo.
Create it from the example if absent, then verify the two vars are set **without printing the file** (it may hold other secrets) — `source` it and echo only a pass/fail signal, never the values:
```bash
# Resolve the repo root first; an unguarded $(git ...) would expand to
# "/.env.local" outside a repo and touch a system-level file.
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$ROOT" ]; then
echo "MISSING (run this from inside the connected git repo)"
else
ENV_FILE="$ROOT/.env.local"
[ -f "$ENV_FILE" ] || cp .env.local.example "$ENV_FILE"
# Source inside a subshell so the vars never leak into your environment.
( source "$ENV_FILE" 2>/dev/null
[ -n "$DATA_APP_MB_URL" ] && [ "$DATA_APP_MB_URL" != "mb_replace_me" ] &&
[ -n "$DATA_APP_MB_API_KEY" ] && [ "$DATA_APP_MB_API_KEY" != "mb_replace_me" ]
) && echo "creds present" || echo "MISSING"
fi
```
If it prints `MISSING`, **ask the user to fill `DATA_APP_MB_URL` (the running Metabase instance) and `DATA_APP_MB_API_KEY` (Admin → Authentication → API keys) in `<repo>/.env.local` themselves** — up front, before anything needs the key.
> **Never ask the user to paste the API key into the chat, and never `cat` / `echo` / print `.env.local` or its variables.** It's git-ignored and may hold *other* secrets — the file's contents and the key must never enter the conversation or your context. Every command that needs the key `source`s the file (as above) so the shell uses the value directly; you only ever see the `creds present` / `MISSING` signal, never the secret itself. (`creds present` only means both vars are filled and not the default `mb_replace_me` placeholder — not that the URL or key are valid; a bad key surfaces later when a request fails.)
5. `npm install` (or whichever package manager the user prefers — the template ships with no lockfile, so `npm` / `yarn` / `pnpm` / `bun` all work; use the project's existing lockfile if one appears post-clone).
6. **Fix the app's `.gitignore` so the lockfile *and* the built bundle get committed.** Two things must end up tracked in the remote-sync repo:
- **Lockfile** — strip the lockfile-ignoring block (the chunk between `# Lockfiles —` and `bun.lockb`, covering `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml` / `bun.lock` / `bun.lockb`) so the project commits its lockfile for reproducible installs.
- **The built bundle** — Metabase serves the file at the `path` declared in `data_app.yaml` (the template builds to `dist/index.js`, the default `path`) straight from the committed Git tree, so **that file must be committed**. If the template's `.gitignore` ignores `dist/` (or wherever your build outputs), remove that line.
**Verify with `git status`** — after `npm install` + a build, both the generated lockfile and the built bundle (the file `path` points at) must appear as untracked/committable files. If either doesn't, the relevant `.gitignore` line is still there; remove it and re-check. Do **not** skip this — agents have repeatedly shipped projects with no committed lockfile or an un-synced bundle.
7. `npm run dev` and confirm the preview at http://localhost:5174 renders the starter "Hello, data app" message.
8. If the preview hits CORS, add `http://localhost:5174` under Admin → Embedding → Embedded analytics SDK → CORS.
9. **Edit `data_app.yaml`** (it ships with the template, in the app directory). This is the per-app config Metabase reads on sync — one file per app. Fill in its fields for this app:
```yaml
name: Sales App # display name shown in the admin UI
description: Pipeline health and quota attainment by region # optional — see below
path: ./dist/index.js # bundle path, relative to this app's directory — leave as-is unless you change the build output
# allowed_hosts: # optional — external origins the app may fetch/XHR (see below)
# - https://api.example.com
# - https://*.internal.acme.com
```
Commit it alongside the built bundle (the file `path` points at).
**`description`** — optional: a single short sentence saying what the app
does, shown under its name in the admin UI so admins can tell apps apart at a
glance. Sync folds any whitespace into single spaces and rejects anything over
255 characters; the admin list wraps what is left rather than cutting it off,
so a sentence reads well there and a paragraph crowds out the rows around it.
Replace the template's placeholder with a real sentence about *this* app, or
delete the line entirely if it adds nothing beyond the name.
**`allowed_hosts`** — only needed if the app calls an **external** API directly
with `fetch`/`XHR`. The sandbox blocks all network egress by default; listing an
origin here (exact or a `*.` subdomain wildcard) opens it in both `npm run dev`
(dev-server CSP) and Metabase (iframe CSP + sandbox). Do **not** list the
Metabase instance — Metabase data is read via the `useMetabaseQuery` data hooks
and written via `useAction` (the SDK handles auth), never raw `fetch`. Leave
`allowed_hosts` out entirely when the app only talks to Metabase.
Native `<form action="…">` submissions and `<iframe src="…">`/navigations obey
the **same** allowlist. Prefer a client-side `<form onSubmit>` that
`preventDefault`s and writes via `useAction`/`fetch`: a native submit
*navigates the sandboxed iframe away* from the app. If you do use one, the
target host must be in `allowed_hosts` or it's blocked (`form-action` for
submits, `frame-src` for embeds/navigations). A host you navigate to or embed
must also permit framing (`X-Frame-Options`/`frame-ancestors`) — many public
sites don't.
## Step 5 — Verify the starter app
At this point the data app exists. Keep this workflow focused on creating the
project scaffold and proving the starter bundle works.
1. Run `npm run typecheck`.
2. Run `npm run build`.
3. Confirm `git status` shows the app source, lockfile, `data_app.yaml`, and the
built bundle (`dist/index.js` by default) as committable files.
4. If the user asked for a live preview, run `npm run dev` and confirm the
starter "Hello, data app" screen renders through the sandbox preview.
5. With the preview open, check the diagnostics feed once — it is the only place
runtime failures appear to you (see *Reading the diagnostics feed*):
```bash
curl -s "http://localhost:5174/__data-app/diagnostics?startEventId=0"
```
Expect `clients: 1` and no entries with `"alert": true`. `clients: 0` means no
preview tab is open, so an empty feed proves nothing.
Stop here if the user only asked to create, scaffold, or set up a data app.
If the next task is to build or iterate on the actual app UI — especially if it
mentions an existing data app, Metabase data, generated schema files, saved
questions, tables, metrics, actions, filters, semantic-layer entities, or data
hooks — treat it as a separate existing-data-app editing task. Use the agent's
normal skill-discovery flow from those terms. Do not expand this scaffold skill
with data-layer authoring rules.
**Do not modify `src/index.tsx` or `tsconfig.json` unless the change is genuinely required.** The whole build/dev setup lives in the SDK behind `dataAppConfig()` (which also serves the dev HTML shell — there's no `index.html` to edit), so `vite.config.ts` is just:
```ts
import { dataAppConfig } from "@metabase/embedding-sdk-react/data-app-dev/config";
export default dataAppConfig();
```
`dataAppConfig` exposes only a curated set of overrides (currently just `port`). The whole contract — factory shape, externals/globals, the dev sandbox entry, CSS inlining, and SVG-as-component support — is baked in and **can't** be overridden; that's deliberate, so a data app can't drift from what Metabase loads. There's no local build config to touch (and no `index.html` — the SDK's dev server serves the HTML shell), and tweaks to `src/index.tsx` still risk breaking the factory shape and silently break drill popups and routing.
There is intentionally **no escape hatch** for extra Vite plugins, aliases, or `define`s — `port` is the only knob. If you think you need more, you almost certainly don't; solve it in `src/` instead.
**After every meaningful round of edits, run `npm run typecheck`.** It runs `tsc --noEmit` over `src/` and `vite.config.ts` — catches wrong prop shapes against the SDK types, broken refactors, missing imports, etc. The Vite dev server does NOT typecheck (it only transpiles), so errors that would fail a production CI run can sit invisibly in a passing `npm run dev` session. Run it before declaring a task complete.
**Before handoff, re-check package hygiene.** `@metabase/embedding-sdk-react` should use the expected data-app SDK source/tag for the target environment. No date picker dependency should be installed when the app only needs date ranges — not `react-datepicker`, `react-day-picker`, `flatpickr`, or a UI suite's picker (`@mui/x-date-pickers`, `antd`, `rsuite`, …); that is `DateRangePopover` from `@metabase/embedding-sdk-react/data-app`. `@types/react-datepicker` should not be installed unless the chosen `react-datepicker` version actually needs it.
## Reading the diagnostics feed
`npm run dev` serves everything the toolbar shows as JSON — the only way *you*
see runtime failures, since sandbox blocks, CSP refusals, failed queries and
uncaught errors reach neither the terminal nor `npm run typecheck`. Check it
after any change you can't verify by reading the code.
**Loop:** note `nextEventId` before editing → make the change (rebuilds
automatically) → re-read. `startEventId` is inclusive and survives page reloads.
```bash
curl -s "http://localhost:5174/__data-app/diagnostics?startEventId=0"
```
```jsonc
{
"entries": [{
"eventId": 31, "kind": "blocked-network", "alert": true,
View on GitHub