metabase-data-app-setup

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Create a Metabase Data App

创建 Metabase 数据应用

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.
Metabase 数据应用(data-app) 是一个单独的 JS 包,宿主会在 Near Membrane 沙箱中加载它,并在自身的 React 树中渲染。该脚手架是一个 Vite + React + TypeScript 项目:源码位于
src/
目录下;开发服务器通过与 Metabase 生产环境完全相同的 Near Membrane 沙箱 + 畸变规则,对接真实的 Metabase 来预览应用——因此
npm run dev
的表现与生产环境一致,包括应用打包的第三方库也遵循相同规则;
npm run build
会生成单个
dist/index.js
文件。(由于沙箱运行的是构建后的包,代码变更会触发重新构建并执行软重载——在沙箱中重新评估包并重新挂载应用,保持认证/SDK 加载状态——而非模块热替换;组件状态会重置,但不会刷新整个浏览器。)开发预览还会在角落显示一个 ⚠ 诊断(Diagnostics) 工具栏,用于捕获运行时错误——包括沙箱中原本不透明的 API 拦截消息——让错误得以暴露而非被静默吞掉。同样的数据以 JSON 格式在
http://localhost:5174/__data-app/diagnostics
提供,这是你读取这些信息的方式(见下文“读取诊断信息流”)——你只有 shell 没有浏览器,否则这些错误在终端中是不可见的。
数据应用通过 Git 提供,而非上传。 单个仓库通过远程同步(Admin → Settings → Remote sync)连接到 Metabase。每个应用都位于仓库内的独立目录
data_apps/<app>/
中——包含应用源码、
data_app.yaml
(配置名称/路径),以及提交到
data_app.yaml
声明的
path
处的构建后包(默认为
dist/index.js
)。每次远程同步导入时,Metabase 会为每个目录生成一个应用,并通过
/apps/<slug>
URL 提供服务,其中 slug 就是目录名称。因此本技能始终将脚手架搭建在已连接仓库的
data_apps/<app>/
目录中
,绝不会作为独立项目创建。
脚手架模板随本技能一同提供,位于
./template/
目录
——这是一个随技能一起安装的 Vite + React + TypeScript 项目。第 3 步只是将其复制到应用目录中;之后本技能会引导你完成定制和首个应用内容的创建步骤——绝不会从零生成项目文件。如果你发现自己在手动编写
package.json
、
vite.config.ts
、
tsconfig.json
或
src/index.tsx
,请停下来——改用复制模板的方式。

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.
  • “搭建一个新的数据应用” / “创建一个 Metabase 数据应用” / “设置数据应用项目”
  • “我想构建一个数据应用” / 任何开发数据应用的模糊意图
  • 启动一个将生成数据应用包的全新 Agent 任务。
  • 对于现有数据应用项目,如果任务是构建界面、使用 Metabase 数据、生成或刷新 schema 文件、关联已保存的问题/表/指标/操作、添加过滤器或编写数据 hooks,不要使用本技能。将这些视为现有数据应用的编辑任务,根据用户措辞使用 Agent 正常的技能发现流程。

Step 1 — Locate the remote-sync repository

步骤 1 — 定位远程同步仓库

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.
数据应用存放在通过远程同步连接到 Metabase 的 Git 仓库中。搭建脚手架前先找到它:
  • 询问:“你是否已经有一个通过远程同步连接到此 Metabase 的 Git 仓库?”
    • 是 → 询问其本地路径。
    • 否 → 请用户创建一个(一个普通的 Git 仓库,他们将在 Admin → Settings → Remote sync 中连接)并分享其路径。本技能不会创建或连接仓库——这由用户负责。
  • 验证该路径存在且是一个 Git 工作树(包含
    .git
    目录)。此仓库是以下所有步骤的工作目录。

Step 2 — Name the app and create its directory

步骤 2 — 命名应用并创建其目录

  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.
  1. 搭建脚手架前先确定应用的目录名称——它会被直接用作 slug(即
    /apps/<slug>
    URL),因此必须使用短横线命名法:小写字母、数字和单个短横线(
    [a-z0-9]+(?:-[a-z0-9]+)*
    ),例如
    sales-overview
    。任何其他格式(大写、空格、下划线)都会在同步时被拒绝。如果用途还不明确,用一行话询问“这个应用是做什么的?”并提议一个名称;请用户确认。
  2. 确保
    <repo>/data_apps/
    存在;如果不存在则创建。
  3. 创建
    <repo>/data_apps/<slug>/
    。如果该目录已存在,将其视为现有项目(见下文)——未经确认绝不要覆盖。

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.
如果
<repo>/data_apps/<slug>/
中已有项目,验证它是否与当前的
data-app-template
匹配。检查所有以下项:
  1. vite.config.ts
    是一行代码:
    export default dataAppConfig()
    (来自
    @metabase/embedding-sdk-react/data-app-dev/config
    )。没有本地
    config/
    目录:整个包契约(externals/globals、开发沙箱入口、CSS/SVG 处理)都位于该 SDK 配置中,而非脚手架中。
    dataAppConfig
    仅接受一组精选的覆盖项(目前只有
    port
    );契约插件始终会应用,且无法被覆盖。
  2. src/index.tsx
    默认导出一个
    DataAppFactory
    (类型来自
    @metabase/embedding-sdk-react/data-app
    ),返回
    { component, providerProps? }
    (无参数)。
所有检查都通过 → 符合模板结构。询问:“是扩展此应用,还是在其他 slug 下搭建新应用?”如果扩展 → 跳过复制步骤,直接编辑
src/
。如果新建 → 选择另一个 slug 并从步骤 2 重新开始。
任何检查失败 → 不符合模板结构(旧版脚手架或已偏离)。停止。 告知用户其结构与当前模板不同,扩展它有破坏包契约的风险,并询问是要 (1) 迁移它,(2) 在新 slug 下重新搭建并移植代码,还是 (3) 自行承担风险继续。等待用户回答。
未经明确确认,绝不要覆盖现有文件。

Step 3 — Copy the template into the app directory

步骤 3 — 将模板复制到应用目录

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>"
模板随本技能一同提供,位于
./template/
目录(通过
skills add metabase/metabase/skills#release-x.<major>.x
与技能一起安装)。将其复制到应用目录:
bash
APP_DIR="<repo>/data_apps/<slug>"

<skill-dir>
= the directory this SKILL.md was loaded from

<skill-dir>
= 加载此 SKILL.md 的目录

(e.g.
.claude/skills/metabase-data-app-setup
); the template is its
template/
subfolder.

(例如
.claude/skills/metabase-data-app-setup
);模板是其
template/
子文件夹。

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`**.

The copy includes two root-level directories, `queries/` and `actions/`, each holding only a `README.md`. Keep both, even while empty: every query the app runs is a `defineQuery(...)` export in `queries/`, every action a `defineAction(...)` export in `actions/`, and the hooks refuse anything else at compile time. Read both READMEs before writing the first `useMetabaseQuery` / `useMetabaseQueryObject` / `useAction` call.
cp -R "<skill-dir>/template/." "$APP_DIR/"

数据应用是远程同步仓库的*子目录*,而非独立仓库——因此这是普通复制,绝不要嵌套执行 `git clone` / `git init`。以下所有操作都在 **`$APP_DIR` 内**运行。

复制的内容包括两个根级目录 `queries/` 和 `actions/`,每个目录中只有一个 `README.md`。请保留这两个目录,即使是空的:应用运行的每个查询都是 `queries/` 中的 `defineQuery(...)` 导出,每个操作都是 `actions/` 中的 `defineAction(...)` 导出,hooks 在编译时会拒绝任何其他形式的内容。在编写第一个 `useMetabaseQuery` / `useMetabaseQueryObject` / `useAction` 调用之前,请阅读这两个 README。

Step 4 — Customize

步骤 4 — 定制

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
    version: 1             # data app contract version — leave as-is (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.
    version
    — the data app contract version this app's code targets, a whole number. The template ships the version this skill targets; do not change it by hand. Metabase bumps the version it serves only on a breaking change to the contract. An app on an older version is marked Outdated in the admin list, hidden from every other user, and refuses to open until it is migrated to the current contract, its
    version
    raised to match, rebuilt, and synced. Migrating an app from one version to the next is a separate, instructed procedure; never do it ad hoc.
    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.
模板放入
<repo>/data_apps/<slug>/
后(以下所有操作都在该目录中运行):
  1. 编辑
    package.json
    的
    name
    字段,使其与 slug 匹配。
  2. 将
    @metabase/embedding-sdk-react
    固定到已发布的 data-apps 标签(模板默认使用
    *
    ):
    bash
    npm install @metabase/embedding-sdk-react@64-alpha
    这会解析为当前内部测试的 SDK 构建版本,包含
    @metabase/embedding-sdk-react/data-app
    入口点(应用的 API)和
    vite.config.ts
    使用的
    @metabase/embedding-sdk-react/data-app-dev/config
    入口点(开发/构建预设,提供沙箱入口)。不要使用
    latest
    或
    64-stable
    ,因为数据应用尚未正式发布。
  3. 确保仓库根目录的
    .gitignore
    忽略
    .env.local
    ——在创建任何凭证文件之前执行此操作,这样密钥永远不会被提交。如果仓库没有
    .gitignore
    则创建,然后如果缺少该条目则添加:
    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"
      # 如果仓库根目录不存在 .gitignore 则创建,然后确保忽略 `.env.local`
      # 这样凭证文件(下一步)永远不会被提交。
      [ -f "$GITIGNORE" ] || : > "$GITIGNORE"
      grep -qxF ".env.local" "$GITIGNORE" || echo ".env.local" >> "$GITIGNORE"
    fi
  4. 在仓库根目录设置 Metabase 凭证——即
    <repo>/.env.local
    (通常在应用目录的上两级),而不是应用目录。一个
    .env.local
    可供仓库中的所有数据应用使用。
    如果文件不存在则从示例文件创建,然后验证两个变量是否已设置,不要打印文件内容(它可能包含其他密钥)——
    source
    该文件并仅输出通过/失败信号,绝不输出值:
    bash
    # 首先解析仓库根目录;未受保护的 $(git ...) 在仓库外会展开为
    # "/.env.local",从而修改系统级文件。
    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"
      # 在子 shell 中 source,这样变量永远不会泄漏到你的环境中。
      ( 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
    如果输出
    MISSING
    ,请用户自行在
    <repo>/.env.local
    中填写
    DATA_APP_MB_URL
    (运行中的 Metabase 实例地址)和
    DATA_APP_MB_API_KEY
    (Admin → Authentication → API keys)
    ——提前完成,在任何需要密钥的操作之前。
    绝不要要求用户将 API 密钥粘贴到聊天中,也绝不要
    cat
    /
    echo
    / 打印
    .env.local
    或其变量。
    该文件已被 git 忽略,可能包含其他密钥——文件内容和密钥绝不能进入对话或你的上下文。每个需要密钥的命令都会
    source
    该文件(如上所述),以便 shell 直接使用该值;你只会看到
    creds present
    /
    MISSING
    信号,永远看不到密钥本身。(
    creds present
    仅表示两个变量都已填写且不是默认的
    mb_replace_me
    占位符——并不表示 URL 或密钥有效;无效密钥会在后续请求失败时暴露出来。)
  5. 执行
    npm install
    (或用户偏好的任何包管理器——模板没有附带 lockfile,因此
    npm
    /
    yarn
    /
    pnpm
    /
    bun
    都可以;如果克隆后项目有现有的 lockfile,则使用对应的包管理器)。
  6. 修改应用的
    .gitignore
    ,使 lockfile 和 构建后的包都能被提交。
    远程同步仓库中必须跟踪两样东西:
    • Lockfile——删除忽略 lockfile 的代码块(
      # Lockfiles —
      和
      bun.lockb
      之间的部分,涵盖
      package-lock.json
      /
      yarn.lock
      /
      pnpm-lock.yaml
      /
      bun.lock
      /
      bun.lockb
      ),这样项目会提交 lockfile 以实现可复现的安装。
    • 构建后的包——Metabase 直接从已提交的 Git 树中提供
      data_app.yaml
      中声明的
      path
      处的文件(模板构建到
      dist/index.js
      ,即默认
      path
      ),因此该文件必须被提交。如果模板的
      .gitignore
      忽略了
      dist/
      (或你的构建输出目录),请删除该行。 用
      git status
      验证
      ——在
      npm install
      + 构建之后,生成的 lockfile 和构建后的包(
      path
      指向的文件)都必须显示为未跟踪/可提交的文件。如果其中任何一个没有,说明相关的
      .gitignore
      行仍然存在;删除它并重新检查。不要跳过这一步——Agent 曾多次交付没有提交 lockfile 或包未同步的项目。
  7. 执行
    npm run dev
    ,并确认 http://localhost:5174 上的预览渲染了初始的“Hello, data app”消息。
  8. 如果预览遇到 CORS 问题,将
    http://localhost:5174
    添加到 Admin → Embedding → Embedded analytics SDK → CORS 中。
  9. 编辑
    data_app.yaml
    (它随模板一起提供,位于应用目录中)。这是 Metabase 在同步时读取的每个应用的配置——每个应用一个文件。为这个应用填写其字段:
    yaml
    name: Sales App        # 管理界面中显示的显示名称
    description: Pipeline health and quota attainment by region  # 可选 — 见下文
    version: 1             # 数据应用契约版本 — 保持不变(见下文)
    path: ./dist/index.js  # 包路径,相对于此应用目录 — 除非你更改构建输出,否则保持不变
    # allowed_hosts:       # 可选 — 应用可能 fetch/XHR 的外部源(见下文)
    #   - https://api.example.com
    #   - https://*.internal.acme.com
    将其与构建后的包(
    path
    指向的文件)一起提交。
    description
    ——可选:一句简短的话说明应用的用途,显示在管理界面的应用名称下方,让管理员一眼就能区分不同的应用。同步会将任何空白字符折叠为单个空格,并拒绝超过 255 个字符的内容;管理列表会换行显示剩余内容,而不是截断,因此一句话在那里显示效果很好,而段落会挤占周围的行。用关于此应用的真实句子替换模板的占位符,如果除了名称之外没有更多信息,也可以完全删除该行。
    version
    ——此应用代码目标的数据应用契约版本,为整数。模板附带了本技能目标的版本;不要手动更改。Metabase 仅在契约发生破坏性变更时才会提高其提供的版本。使用旧版本的应用在管理列表中会被标记为过时(Outdated),对所有其他用户隐藏,并拒绝打开,直到它被迁移到当前契约、
    version
    提高到匹配版本、重新构建并同步。将应用从一个版本迁移到下一个版本是一个单独的、有指导的流程;绝不要临时随意操作。
    allowed_hosts
    ——仅当应用使用
    fetch
    /
    XHR
    直接调用外部 API 时才需要。沙箱默认阻止所有网络出站流量;在此处列出源(精确匹配或
    *.
    子域名通配符)会在
    npm run dev
    (开发服务器 CSP)和 Metabase(iframe CSP + 沙箱)中都开放该源。不要列出 Metabase 实例——Metabase 数据通过
    useMetabaseQuery
    数据 hooks 读取,通过
    useAction
    写入(SDK 处理认证),绝不使用原始
    fetch
    。如果应用仅与 Metabase 通信,请完全省略
    allowed_hosts
    。
    原生
    <form action="…">
    提交和
    <iframe src="…">
    /导航也遵循相同的允许列表。优先使用客户端的
    <form onSubmit>
    ,调用
    preventDefault
    并通过
    useAction
    /
    fetch
    写入:原生提交会将沙箱化的 iframe 导航离开应用。如果你确实使用原生提交,目标主机必须在
    allowed_hosts
    中,否则会被阻止(提交对应
    form-action
    ,嵌入/导航对应
    frame-src
    )。你导航到或嵌入的主机还必须允许被框架嵌入(
    X-Frame-Options
    /
    frame-ancestors
    )——许多公共网站不允许。

Step 5 — Verify the starter app

步骤 5 — 验证初始应用

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.
此时数据应用已创建。请保持本工作流专注于创建项目脚手架和验证初始包是否正常工作。
  1. 运行
    npm run typecheck
    。
  2. 运行
    npm run build
    。
  3. 确认
    git status
    显示应用源码、lockfile、
    data_app.yaml
    和构建后的包(默认为
    dist/index.js
    )都是可提交的文件。
  4. 如果用户要求实时预览,运行
    npm run dev
    并确认初始的“Hello, data app”屏幕通过沙箱预览正确渲染。
  5. 预览打开后,检查一次诊断信息流——这是你能看到运行时故障的唯一地方(见读取诊断信息流):
    bash
    curl -s "http://localhost:5174/__data-app/diagnostics?startEventId=0"
    预期结果是
    clients: 1
    且没有
    "alert": true
    的条目。
    clients: 0
    表示没有打开的预览标签页,因此空的信息流不能说明任何问题。
如果用户只是要求创建、搭建或设置数据应用,到此为止。
如果下一个任务是构建或迭代实际的应用 UI——特别是如果提到了现有数据应用、Metabase 数据、生成的 schema 文件、已保存的问题、表、指标、操作、过滤器、语义层实体或数据 hooks——将其视为单独的现有数据应用编辑任务。根据这些术语使用 Agent 正常的技能发现流程。不要用数据层编写规则扩展本脚手架技能。
不要修改
src/index.tsx
或
tsconfig.json
,除非确实有必要。
整个构建/开发设置都位于
dataAppConfig()
背后的 SDK 中(它还提供开发用的 HTML 外壳——没有可编辑的
index.html
),因此
vite.config.ts
只是:
ts
import { dataAppConfig } from "@metabase/embedding-sdk-react/data-app-dev/config";

export default dataAppConfig();
dataAppConfig
仅公开一组精选的覆盖项(目前只有
port
)。整个契约——工厂形状、externals/globals、开发沙箱入口、CSS 内联和 SVG 作为组件支持——都是内置的,无法被覆盖;这是有意为之的,这样数据应用就不会偏离 Metabase 加载的预期。没有本地构建配置可以修改(也没有
index.html
——SDK 的开发服务器提供 HTML 外壳),调整
src/index.tsx
仍有可能破坏工厂形状,并静默破坏钻取弹窗和路由。
故意没有为额外的 Vite 插件、别名或
define
提供逃生舱——
port
是唯一的调节旋钮。如果你认为需要更多,几乎可以肯定你不需要;改为在
src/
中解决。
每轮有意义的编辑后,运行
npm run typecheck
。
它会对
src/
和
vite.config.ts
运行
tsc --noEmit
——捕获与 SDK 类型不匹配的 prop 形状、失败的重构、缺失的导入等。Vite 开发服务器不会进行类型检查(它只进行转译),因此会导致生产 CI 运行失败的错误可能在
npm run dev
会话中不可见地存在。在宣布任务完成前运行它。
交接前,重新检查包的整洁性。
@metabase/embedding-sdk-react
应使用目标环境预期的数据应用 SDK 源/标签。当应用只需要日期范围时,不应安装任何日期选择器依赖——不要安装
react-datepicker
、
react-day-picker
、
flatpickr
或 UI 套件的选择器(
@mui/x-date-pickers
、
antd
、
rsuite
等);应该使用
@metabase/embedding-sdk-react/data-app
中的
DateRangePopover
。除非所选的
react-datepicker
版本确实需要,否则不应安装
@types/react-datepicker
。

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,
    "summary": "Blocked fetch to api.example.com (not in allowed_hosts)",
    "detail": null,   // stack frames, when any
    "hint": "Add https://api.example.com to allowed_hosts in data_app.yaml …",
    "buildId": 7      // the bundle generation that reported it
  }],
  "clients": 1,       // connected preview tabs — 0 means nothing ran
  "buildId": 7,       // the generation running now
  "staleEntries": 164, // held back, reported by an older build — see below
  "nextEventId": 32   // pass back as ?startEventId=
}
clients: 0
does not mean healthy
— it means no preview tab is open, so an empty
entries
proves nothing. Open
http://localhost:5174
first.
The feed answers for the bundle that is running, not for everything that ever happened. Every save rebuilds and remounts the app, so a multi-step edit runs through builds that don't compile or don't render — errors from those describe code the preview has already replaced. They're withheld and counted in
staleEntries
; read mid-edit and you see the current build's failures, not a pile of them. Nothing is lost: the rebuild re-runs the app from scratch, so anything still broken reports itself again under the new
buildId
, and
?includeStale=true
hands back the withheld entries when comparing builds is the point. A failed build doesn't advance
buildId
— the preview keeps running the last good bundle, so its entries stay current.
Triage by
kind
(always read
hint
— it names the exact fix; never soften a
summary
when reporting it):
kind
Fix
blocked-network
/
csp-violation
add the origin to
allowed_hosts
in
data_app.yaml
, then restart
npm run dev
(allowlist + CSP are read at boot)
blocked-api
blocked in production too — use an SDK API or drop the call, don't work around it
sdk-call
+
alert: true
a Metabase request failed; fix the query —
summary
has the endpoint and status
error
a real bug;
detail
has the stack
Text is truncated and the buffer holds the last 200 events.
curl -X DELETE .../__data-app/diagnostics
clears it for every reader.
npm run dev
将工具栏显示的所有内容以 JSON 格式提供——这是你看到运行时故障的唯一方式,因为沙箱拦截、CSP 拒绝、查询失败和未捕获的错误既不会出现在终端中,也不会被
npm run typecheck
捕获。在进行任何你无法通过阅读代码验证的更改后检查它。
循环流程: 编辑前记下
nextEventId
→ 进行更改(会自动重新构建)→ 重新读取。
startEventId
是包含性的,且在页面刷新后仍然有效。
bash
curl -s "http://localhost:5174/__data-app/diagnostics?startEventId=0"
jsonc
{
  "entries": [{
    "eventId": 31, "kind": "blocked-network", "alert": true,
    "summary": "Blocked fetch to api.example.com (not in allowed_hosts)",
    "detail": null,   // 栈帧(如果有的话)
    "hint": "Add https://api.example.com to allowed_hosts in data_app.yaml …",
    "buildId": 7      // 报告此事件的包版本
  }],
  "clients": 1,       // 已连接的预览标签页 — 0 表示没有运行任何内容
  "buildId": 7,       // 当前运行的版本
  "staleEntries": 164, // 被保留的、由旧版本报告的条目 — 见下文
  "nextEventId": 32   // 作为 ?startEventId= 的值回传
}
clients: 0
不代表正常
——它表示没有打开的预览标签页,因此空的
entries
不能说明任何问题。先打开
http://localhost:5174
。
信息流反映的是当前运行的包的状态,而不是所有历史事件。 每次保存都会重新构建并重新挂载应用,因此多步骤编辑会经历无法编译或无法渲染的构建——这些构建的错误描述的是预览已经替换的代码。它们会被保留并计入
staleEntries
;在编辑过程中读取,你会看到当前构建的故障,而不是一堆历史错误。没有任何内容会丢失:重新构建会从头重新运行应用,因此任何仍然存在的问题都会在新的
buildId
下再次报告,而当你需要比较构建时,
?includeStale=true
会返回被保留的条目。失败的构建不会推进
buildId
——预览会继续运行最后一个正常的包,因此它的条目保持最新。
按
kind
分类排查(始终阅读
hint
——它给出了确切的修复方法;报告时不要弱化
summary
的内容):
kind
修复方法
blocked-network
/
csp-violation
将源添加到
data_app.yaml
的
allowed_hosts
中,然后重启
npm run dev
(允许列表和 CSP 在启动时读取)
blocked-api
在生产环境中也会被阻止——使用 SDK API 或删除该调用,不要绕过它
sdk-call
+
alert: true
Metabase 请求失败;修复查询——
summary
包含端点和状态码
error
真正的 bug;
detail
包含栈信息
文本会被截断,缓冲区保存最后 200 个事件。
curl -X DELETE .../__data-app/diagnostics
会为所有读者清除它。

Source conventions

源码约定

1. Write TSX

1. 编写 TSX

Plain ESM + TSX, normal package imports:
tsx
// src/components/CustomerCard.tsx
import { StaticQuestion } from "@metabase/embedding-sdk-react";

type Customer = { name: string; questionId: number };

export default function CustomerCard({ customer }: { customer: Customer }) {
  return (
    <article>
      <h3>{customer.name}</h3>
      <StaticQuestion questionId={customer.questionId} height={300} width="100%" />
    </article>
  );
}
使用普通 ESM + TSX,标准包导入:
tsx
// src/components/CustomerCard.tsx
import { StaticQuestion } from "@metabase/embedding-sdk-react";

type Customer = { name: string; questionId: number };

export default function CustomerCard({ customer }: { customer: Customer }) {
  return (
    <article>
      <h3>{customer.name}</h3>
      <StaticQuestion questionId={customer.questionId} height={300} width="100%" />
    </article>
  );
}

2. Structure from the start

2. 从一开始就结构化

Default project layout once the starter app is extended:
queries/               (root level, beside package.json — NOT under src/)
│   └── orders.query.ts   (defineQuery exports; one file per topic)
actions/               (root level, beside package.json — NOT under src/)
│   └── orders.action.ts  (defineAction exports; one file per topic)
src/
├── index.tsx          (template — the factory; don't edit)
├── App.tsx            (routing + composition only)
├── theme.ts
├── metabase.data.ts   (generated schema — see the semantic-layer skill)
├── pages/             (one file per screen)
│   ├── Overview.tsx
│   └── CustomerDetail.tsx
├── components/        (shared UI)
│   └── Card.tsx
├── hooks/             (custom hooks that wrap a query export, never the query itself)
│   └── useCustomers.ts
├── lib/               (pure helpers / derivations)
│   └── format.ts
└── types/             (shared TS types)
    └── customer.ts
Vite bundles everything reachable from
src/index.tsx
into a single
dist/index.js
IIFE — the
src/
layout is purely for your own readability.
queries/
and
actions/
are not:
npm run build
synchronizes exactly those two root-level directories to Metabase, and the query and action hooks accept only the
defineQuery
/
defineAction
exports declared there. A query object written at a hook call, under
src/
, or spread from a definition does not compile (
Property 'definedWithDefineQuery' is missing
); the fix is to move it into
queries/
and import it, never a cast.
If the app has multiple tabs (or any top-level screen switcher), the default — leftmost / first — tab MUST be selected on initial load. The app should never boot to a blank page, an empty shell, or a "nothing selected" state that waits for the user to click. Agents repeatedly forget this. For local-state tabs, initialize the active tab to the first one so the very first render shows it:
tsx
const [active, setActive] = useState(TABS[0].id); // default = leftmost tab
If the tabs are instead backed by URL routes (multiple pages), the same rule applies via the router — see the
metabase-data-app-routing
skill for making the base path
/
resolve to the default tab. Either way, verify by loading the app fresh: the leftmost tab's content is visible immediately and reads as selected.
The build output is one self-contained
.js
file — nothing else.
The backend serves a single bundle, so there are no sidecar files: CSS is inlined into the JS, and every imported asset (images, fonts, SVGs-as-URLs) is base64-inlined as a data URI. So
import logo from "./logo.png"
/
import iconUrl from "./icon.svg"
give you a ready-to-use data-URI string, and SVGs can also be imported as React components with the
?react
suffix (built-in
svgr
):
import Icon from "./icon.svg?react"
. Everything gets baked into
dist/index.js
— just keep large binaries out, since inlining inflates the bundle. (If your editor doesn't recognize a
?react
import, add
declare module "*.svg?react";
to a
.d.ts
in
src/
.)
初始应用扩展后的默认项目布局:
queries/               (根级别,与 package.json 同级 — 不在 src/ 下)
│   └── orders.query.ts   (defineQuery 导出;每个主题一个文件)
actions/               (根级别,与 package.json 同级 — 不在 src/ 下)
│   └── orders.action.ts  (defineAction 导出;每个主题一个文件)
src/
├── index.tsx          (模板 — 工厂函数;不要编辑)
├── App.tsx            (仅路由 + 组合)
├── theme.ts
├── metabase.data.ts   (生成的 schema — 见语义层技能)
├── pages/             (每个屏幕一个文件)
│   ├── Overview.tsx
│   └── CustomerDetail.tsx
├── components/        (共享 UI)
│   └── Card.tsx
├── hooks/             (包装查询导出的自定义 hooks,永远不要包装查询本身)
│   └── useCustomers.ts
├── lib/               (纯辅助函数 / 派生函数)
│   └── format.ts
└── types/             (共享 TS 类型)
    └── customer.ts
Vite 将从
src/index.tsx
可达的所有内容打包到单个
dist/index.js
IIFE 中——
src/
的布局纯粹是为了你自己的可读性。
queries/
和
actions/
则不同:
npm run build
会将这两个根级目录精确同步到 Metabase,并且查询和操作 hooks 只接受在那里声明的
defineQuery
/
defineAction
导出。在 hook 调用处、
src/
下编写的查询对象,或从定义中展开的查询对象都无法编译(
Property 'definedWithDefineQuery' is missing
);修复方法是将其移动到
queries/
中并导入,永远不要使用类型断言。
如果应用有多个标签页(或任何顶级屏幕切换器),默认的——最左侧/第一个——标签页必须在初始加载时被选中。 应用绝不应该启动到空白页面、空外壳或等待用户点击的“未选择任何内容”状态。Agent 经常忘记这一点。对于本地状态的标签页,将活动标签页初始化为第一个,这样第一次渲染就会显示它:
tsx
const [active, setActive] = useState(TABS[0].id); // 默认 = 最左侧标签页
如果标签页由 URL 路由支持(多页面),则通过路由器应用相同的规则——请参阅
metabase-data-app-routing
技能,了解如何使基础路径
/
解析为默认标签页。无论哪种方式,都要通过重新加载应用来验证:最左侧标签页的内容立即可见,并且显示为选中状态。
构建输出是一个独立的
.js
文件——没有其他内容。
后端只提供单个包,因此没有附属文件:CSS 被内联到 JS 中,每个导入的资源(图像、字体、作为 URL 的 SVG)都被 base64 内联为数据 URI。因此
import logo from "./logo.png"
/
import iconUrl from "./icon.svg"
会给你一个即用型数据 URI 字符串,SVG 也可以通过
?react
后缀作为 React 组件导入(内置
svgr
):
import Icon from "./icon.svg?react"
。所有内容都被烘焙到
dist/index.js
中——只需避免大型二进制文件,因为内联会增包大小。(如果你的编辑器无法识别
?react
导入,请在
src/
的
.d.ts
文件中添加
declare module "*.svg?react";
。)

3. Import SDK values from the correct SDK entrypoint

3. 从正确的 SDK 入口点导入 SDK 值

The build externalizes React, the JSX runtimes,
@metabase/embedding-sdk-react
, and
@metabase/embedding-sdk-react/data-app
to sandbox globals in both production and
npm run dev
— in dev the sandbox entry endows them from the npm package, so the bundle runs identically in both. Just import from the entrypoints normally:
tsx
// ✅ correct
import { StaticQuestion } from "@metabase/embedding-sdk-react";
import {
  DataAppRouter,
  DataAppLink,
  useMetabaseQuery,
} from "@metabase/embedding-sdk-react/data-app";

// ❌ wrong — no globalThis pattern; you'd be reading nothing
const { MetabaseProvider, StaticQuestion } = globalThis;
Do NOT render
<MetabaseProvider>
in
App.tsx
.
The dev entry (served by the SDK's dev preset) and the production host both wrap your tree in a provider that lives in their own realm — wrapping inside the bundle would route the SDK's
setState
-via-listener paths through the Near Membrane sandbox and silently break drill popups, plugin init, and similar.
构建会将 React、JSX 运行时、
@metabase/embedding-sdk-react
和
@metabase/embedding-sdk-react/data-app
外部化为沙箱全局变量,在生产环境和
npm run dev
中都是如此
——在开发环境中,沙箱入口从 npm 包中提供这些全局变量,因此包在两种环境中运行方式完全相同。只需正常从入口点导入:
tsx
// ✅ 正确
import { StaticQuestion } from "@metabase/embedding-sdk-react";
import {
  DataAppRouter,
  DataAppLink,
  useMetabaseQuery,
} from "@metabase/embedding-sdk-react/data-app";

// ❌ 错误 — 不要使用 globalThis 模式;你读不到任何内容
const { MetabaseProvider, StaticQuestion } = globalThis;
不要在
App.tsx
中渲染
<MetabaseProvider>
。
开发入口(由 SDK 的开发预设提供)和生产宿主都将你的组件树包装在位于它们自己领域的 provider 中——在包内部包装会导致 SDK 的基于监听器的
setState
路径穿过 Near Membrane 沙箱,从而静默破坏钻取弹窗、插件初始化等功能。

4. Import
react
normally too

4. 也正常导入 react

The build externalizes
react
and
react-dom
, so plain imports resolve the same way in both modes — the production host and the dev sandbox both endow them as sandbox globals:
tsx
import { useState, useEffect, useMemo } from "react";
No
import React from "react"
needed in TSX files
— the template uses the automatic JSX runtime (
jsx: "react-jsx"
in
tsconfig.json
;
dataAppConfig()
includes the React plugin). The compiler injects the JSX-runtime imports it needs (
react/jsx-runtime
in production,
react/jsx-dev-runtime
in dev — both externalized and endowed by the sandbox). Just write JSX and named imports — that's it.
For React types (e.g.
ComponentType
,
ReactNode
,
RefObject
), use named type imports rather than the
React.
namespace:
ts
import type { ComponentType, ReactNode } from "react";
构建会将
react
和
react-dom
外部化,因此普通导入在两种模式下的解析方式相同——生产宿主和开发沙箱都将它们作为沙箱全局变量提供:
tsx
import { useState, useEffect, useMemo } from "react";
TSX 文件中不需要
import React from "react"
——模板使用自动 JSX 运行时(
tsconfig.json
中的
jsx: "react-jsx"
;
dataAppConfig()
包含 React 插件)。编译器会注入它需要的 JSX 运行时导入(生产环境中是
react/jsx-runtime
,开发环境中是
react/jsx-dev-runtime
——两者都被外部化并由沙箱提供)。只需编写 JSX 和命名导入——就这么简单。
对于 React 类型(例如
ComponentType
、
ReactNode
、
RefObject
),使用命名类型导入而不是
React.
命名空间:
ts
import type { ComponentType, ReactNode } from "react";

Theme rules

主题规则

MetabaseProvider
's
theme
(defined in
src/theme.ts
) is the only way SDK component appearance changes. It is NOT a stylesheet for the bundle's own chrome.
FieldPurposeNotes
colors.brand
Accent for SDK widgetsUse the data app's primary color.
colors.brand-hover
,
colors.brand-hover-light
Hover/accent backgrounds for SDK widgetsUse a subtle surface tint that contrasts with hover text. Do not use the same saturated color as
brand
.
colors.charts
Chart paletteArray of strings. Use vivid brand-shade colors only; never pale tints — palette index 1+ may render labels and pale-on-white is invisible.
colors.positive
/
colors.negative
Semantic indicators
colors.background
,
colors.background-secondary
SDK component surfaceMUST match the immediate parent container of the SDK component. If the chart sits inside a white card, use
"white"
. If it sits on a tinted page, use that tint.
colors.text-primary
,
text-secondary
,
text-tertiary
Text on SDK surfacesMUST contrast with
background
.
For white bg: use
#1f2937
or similar dark color. The SDK's default
text-primary
resolves to ~white, so leaving it unset on a white surface produces invisible white text. Always pair
background
and
text-primary
when overriding.
fontFamily
SDK widget typography
Hover colors are a contrast pair. If you set
colors.text-hover
, verify it contrasts with
colors.brand-hover
/
colors.brand-hover-light
in open menus, chart type selectors, and visualization settings dropdowns. For a blue
brand
, use a pale hover surface like
#EAF4FF
, not the brand blue itself.
The theme only styles SDK widgets — never your own UI. For your page background, headers, card wrappers, etc., use inline
style={{ background: "#f5f5f7" }}
or CSS modules on your own elements.
Don't expect per-subtree theming. Multiple
<MetabaseProvider>
s on the page fight for the single CSS-variable slot. If the look needs to change with state, recompute
sdkTheme
at the App level and re-render — the whole tree re-themes.
MetabaseProvider
的
theme
(定义在
src/theme.ts
中)是改变 SDK 组件外观的唯一方式。它不是用于包自身界面的样式表。
字段用途说明
colors.brand
SDK 组件的强调色使用数据应用的主色。
colors.brand-hover
、
colors.brand-hover-light
SDK 组件的悬停/强调背景使用与悬停文本形成对比的柔和表面色调。不要使用与
brand
相同的饱和色。
colors.charts
图表调色板字符串数组。仅使用鲜艳的品牌色调;绝不要使用浅色调——调色板索引 1 及以上可能会渲染标签,浅色在白色背景上是不可见的。
colors.positive
/
colors.negative
语义指示器
colors.background
、
colors.background-secondary
SDK 组件的表面色必须与 SDK 组件的直接父容器匹配。 如果图表位于白色卡片内,使用
"white"
。如果它位于有色页面上,使用该颜色。
colors.text-primary
、
text-secondary
、
text-tertiary
SDK 表面上的文本必须与
background
形成对比。
对于白色背景:使用
#1f2937
或类似的深色。SDK 的默认
text-primary
解析为接近白色,因此在白色表面上不设置它会产生不可见的白色文本。覆盖时始终同时设置
background
和
text-primary
。
fontFamily
SDK 组件的排版
悬停颜色是一对对比色。如果你设置了
colors.text-hover
,请在打开的菜单、图表类型选择器和可视化设置下拉菜单中验证它与
colors.brand-hover
/
colors.brand-hover-light
形成对比。对于蓝色
brand
,使用浅悬停表面如
#EAF4FF
,而不是品牌蓝色本身。
主题仅为 SDK 组件设置样式——永远不要用于你自己的 UI。 对于你的页面背景、标题、卡片包装器等,在你自己的元素上使用内联
style={{ background: "#f5f5f7" }}
或 CSS 模块。
不要期望每个子树都有独立的主题。 页面上的多个
<MetabaseProvider>
会争夺单个 CSS 变量槽。如果外观需要随状态变化,请在 App 级别重新计算
sdkTheme
并重新渲染——整个树都会重新应用主题。

Custom error UI (optional)

自定义错误 UI(可选)

When an embedded question or dashboard can't load — its
questionId
was removed, the user lacks access, or a request fails — the host renders a neutral built-in error state in place of that component (a muted icon + the SDK's message). This needs no setup; leave it alone unless the app has a strong reason to restyle it.
To match the app's look, return your own
errorComponent
in the factory's
providerProps
(the same object that carries
theme
). It's an ordinary
providerProps
key, not a change to the factory shape — safe to add:
tsx
// src/components/AppError.tsx
import type { SdkErrorComponentProps } from "@metabase/embedding-sdk-react";

export default function AppError({ message }: SdkErrorComponentProps) {
  return (
    <div style={{ padding: 16, textAlign: "center", color: "#6b7280" }}>
      {message}
    </div>
  );
}
tsx
// src/index.tsx — add the key alongside `theme`
import AppError from "./components/AppError";

const factory: DataAppFactory = () => ({
  component: App,
  providerProps: { theme: sdkTheme, errorComponent: AppError },
});
Rules:
  • It replaces the error UI for every SDK component in the app, not just not-found — so keep the copy generic. Render
    message
    (or your own wording), not an assumption about which error occurred.
  • message
    is a
    ReactNode
    — render it as-is; don't call
    .toString()
    or index into it.
  • Keep it a small presentational component. It renders inside the host's provider, but treat it as leaf UI — no data hooks, no
    useMetabaseQuery
    .
  • Omit
    errorComponent
    entirely to keep the neutral default. There's no need to re-implement it.
当嵌入的问题或仪表板无法加载时——
questionId
被删除、用户没有访问权限或请求失败——宿主会在该组件的位置渲染一个中性的内置错误状态(一个柔和的图标 + SDK 的消息)。这不需要设置;除非应用有充分的理由重新设置样式,否则不要管它。
为了匹配应用的外观,在工厂的
providerProps
中返回你自己的
errorComponent
(与承载
theme
的是同一个对象)。这是一个普通的
providerProps
键,不是对工厂形状的修改——可以安全添加:
tsx
// src/components/AppError.tsx
import type { SdkErrorComponentProps } from "@metabase/embedding-sdk-react";

export default function AppError({ message }: SdkErrorComponentProps) {
  return (
    <div style={{ padding: 16, textAlign: "center", color: "#6b7280" }}>
      {message}
    </div>
  );
}
tsx
// src/index.tsx — 在 `theme` 旁边添加该键
import AppError from "./components/AppError";

const factory: DataAppFactory = () => ({
  component: App,
  providerProps: { theme: sdkTheme, errorComponent: AppError },
});
规则:
  • 它会替换应用中每个 SDK 组件的错误 UI,不仅仅是未找到的情况——因此文案要保持通用。渲染
    message
    (或你自己的措辞),不要假设发生了什么错误。
  • message
    是一个
    ReactNode
    ——按原样渲染;不要调用
    .toString()
    或对其进行索引。
  • 保持它是一个小型的展示型组件。它在宿主的 provider 内部渲染,但将其视为叶子 UI——没有数据 hooks,没有
    useMetabaseQuery
    。
  • 完全省略
    errorComponent
    以保持中性默认值。没有必要重新实现它。

Available SDK surface

可用的 SDK 接口

The bundle imports React hooks/JSX, SDK components from
@metabase/embedding-sdk-react
, and data-app-specific routing/query APIs from
@metabase/embedding-sdk-react/data-app
.
dataAppConfig()
externalizes these packages at build time, so production references the host's copies at runtime (
globalThis.__metabase_sdk__
/
globalThis.__metabase_data_app__
for SDK packages), and the Vite dev sandbox endows the same globals from the installed npm package.
<MetabaseProvider>
is not rendered by the bundle's
App.tsx
— the dev entry and the host wrap it for their respective modes. Bundle author only renders the content below.
ImportPurpose
React
(from
"react"
)
Hooks (
useState
,
useEffect
, etc.), JSX runtime. Externalized to the host's React via
react: "React"
.
StaticQuestion
Non-drillable question. Props include
questionId
,
card
,
withChartTypeSelector
,
height
,
width
.
InteractiveQuestion
Drillable question. Same props as StaticQuestion plus drill behaviors. Use
card={{ query }}
for ad hoc SDK-rendered questions. See Rendering a chart: Metabase first for choosing between the two and for
visualization
/
visualizationSettings
.
MetabaseCard
Type-only import from
@metabase/embedding-sdk-react
for ad hoc SDK-rendered cards with
visualization
or
visualizationSettings
; use skill discovery for the full generated-query card contract before authoring data-layer code.
CreateQuestion
,
MetabotQuestion
More question variants.
StaticDashboard
,
InteractiveDashboard
,
EditableDashboard
Dashboard variants.
CreateDashboardModal
Modal for new-dashboard flow.
CollectionBrowser
Collection picker.
@metabase/embedding-sdk-react/data-app
exports
Data-app-only helpers for routing, schema-backed data reads, actions, clipboard, and sandbox-safe integration. Treat schema-backed queries, generated schema files, filters, metrics, actions, and other data-layer behavior as existing-data-app editing work; use skill discovery before authoring that code.
DateRangePopover
,
DateRangeCalendar
,
useDateFormatter
(from
@metabase/embedding-sdk-react/data-app
)
Date range selection for filter bars.
DateRangePopover
wraps the app's own trigger element and opens a Metabase-styled range calendar under it;
DateRangeCalendar
is that calendar inline.
value
/
onChange
are
[start, end]
pairs of
YYYY-MM-DD
strings,
null
on either end while half-picked.
useDateFormatter().formatDateRange(value)
makes the trigger's label in the instance's locale, without the UTC-parsing bug of
new Date("YYYY-MM-DD")
. Use these instead of
<input type="date">
or a third-party picker — no dependency, no CSS import.
包从
react
导入 React hooks/JSX,从
@metabase/embedding-sdk-react
导入 SDK 组件,从
@metabase/embedding-sdk-react/data-app
导入数据应用特有的路由/查询 API。
dataAppConfig()
在构建时将这些包外部化,因此生产环境在运行时引用宿主的副本(SDK 包对应
globalThis.__metabase_sdk__
/
globalThis.__metabase_data_app__
),而 Vite 开发沙箱从已安装的 npm 包提供相同的全局变量。
<MetabaseProvider>
不是由包的
App.tsx
渲染的——开发入口和宿主会为各自的模式包装它。包作者只需渲染下面的内容。
导入用途
React
(来自
"react"
)
Hooks(
useState
、
useEffect
等)、JSX 运行时。通过
react: "React"
外部化为宿主的 React。
StaticQuestion
不可钻取的问题。Props 包括
questionId
、
card
、
withChartTypeSelector
、
height
、
width
。
InteractiveQuestion
可钻取的问题。与 StaticQuestion props 相同,加上钻取行为。对于临时的 SDK 渲染问题,使用
card={{ query }}
。关于两者的选择以及
visualization
/
visualizationSettings
,请参阅渲染图表:优先使用 Metabase。
MetabaseCard
从
@metabase/embedding-sdk-react
导入的仅类型类型,用于带有
visualization
或
visualizationSettings
的临时 SDK 渲染卡片;在编写数据层代码之前,使用技能发现了解完整的生成查询卡片契约。
CreateQuestion
、
MetabotQuestion
更多问题变体。
StaticDashboard
、
InteractiveDashboard
、
EditableDashboard
仪表板变体。
CreateDashboardModal
新建仪表板流程的模态框。
CollectionBrowser
集合选择器。
@metabase/embedding-sdk-react/data-app
导出
仅用于数据应用的辅助工具,用于路由、基于 schema 的数据读取、操作、剪贴板和沙箱安全集成。将基于 schema 的查询、生成的 schema 文件、过滤器、指标、操作和其他数据层行为视为现有数据应用的编辑工作;在编写该代码之前使用技能发现。
DateRangePopover
、
DateRangeCalendar
、
useDateFormatter
(来自
@metabase/embedding-sdk-react/data-app
)
用于筛选栏的日期范围选择。
DateRangePopover
包装应用自己的触发元素,并在其下方打开 Metabase 样式的范围日历;
DateRangeCalendar
是内联的日历。
value
/
onChange
是
[start, end]
对,格式为
YYYY-MM-DD
字符串,未选择完时某一端为
null
。
useDateFormatter().formatDateRange(value)
会根据实例的区域设置生成触发器标签,不会出现
new Date("YYYY-MM-DD")
的 UTC 解析 bug。使用这些而不是
<input type="date">
或第三方选择器——无需依赖,无需导入 CSS。

Blocked APIs

被阻止的 API

The Near Membrane sandbox throws at runtime on these globals. Use the endowed replacement instead:
BlockedUse instead
Network —
fetch
,
XMLHttpRequest
,
WebSocket
Use the SDK/data-app APIs for Metabase reads and writes — never raw
fetch
. Raw
fetch
/
XHR
reach only the external origins listed in
data_app.yaml
's
allowed_hosts
(Step 4); everything else (including the Metabase origin) throws.
WebSocket
is always blocked.
UI dialogs —
alert
,
confirm
,
prompt
Render a React modal in your own tree.
Storage —
localStorage
,
sessionStorage
,
indexedDB
,
document.cookie
Treat the data app as stateless across reloads; persist via a Metabase action.
Window / history navigation —
window.open
,
history.pushState
,
history.replaceState
useDataAppLocation().navigate
for in-app;
<a target="_blank" rel="noopener">
for external.
Clipboard —
document.execCommand("copy")
,
navigator.clipboard
copy
(write-only):
import { copy } from "@metabase/embedding-sdk-react/data-app"
, then
await copy(text)
from a user event. There is no read/paste API — that would let a bundle exfiltrate whatever the user copied.
Other
navigator.*
device APIs
—
geolocation
, etc.
Not available.
Global
document
/
window
listeners
for typing/clipboard events —
keydown
,
keyup
,
keypress
,
beforeinput
,
input
,
paste
,
copy
,
cut
,
before*paste/copy/cut
,
compositionstart/update/end
,
storage
Attach the listener to your own element, or use the React handler (
onKeyDown
,
onPaste
, …) on the specific input/container. The same listener on a script-owned element still works.
Rule of thumb: if you're about to touch
window.X
,
document.X
,
navigator.X
,
history.X
, or any storage global, stop and pick the endowed replacement above. The endowed surface (React + React DOM + SDK components + data hooks +
useAction
+ DataAppRouter +
DateRangePopover
+
copy
) covers every routine need; anything outside it is intentionally unreachable.
Near Membrane 沙箱在运行时会对这些全局变量抛出错误。请改用提供的替代方案:
被阻止的 API替代方案
网络 —
fetch
、
XMLHttpRequest
、
WebSocket
使用 SDK/数据应用 API 进行 Metabase 读写——永远不要用原始
fetch
。原始
fetch
/
XHR
只能访问
data_app.yaml
的
allowed_hosts
(步骤 4)中列出的外部源;其他所有内容(包括 Metabase 源)都会抛出错误。
WebSocket
始终被阻止。
UI 对话框 —
alert
、
confirm
、
prompt
在你自己的组件树中渲染 React 模态框。
存储 —
localStorage
、
sessionStorage
、
indexedDB
、
document.cookie
将数据应用视为跨重新加载无状态的;通过 Metabase 操作持久化数据。
窗口 / 历史导航 —
window.open
、
history.pushState
、
history.replaceState
应用内导航使用
useDataAppLocation().navigate
;外部链接使用
<a target="_blank" rel="noopener">
。
剪贴板 —
document.execCommand("copy")
、
navigator.clipboard
copy
(仅写):
import { copy } from "@metabase/embedding-sdk-react/data-app"
,然后在用户事件中调用
await copy(text)
。没有读取/粘贴 API——这会让包能够窃取用户复制的任何内容。
其他
navigator.*
设备 API
—
geolocation
等
不可用。
用于输入/剪贴板事件的全局
document
/
window
监听器
—
keydown
、
keyup
、
keypress
、
beforeinput
、
input
、
paste
、
copy
、
cut
、
before*paste/copy/cut
、
compositionstart/update/end
、
storage
将监听器附加到你自己的元素上,或者在特定的输入/容器上使用 React 处理器(
onKeyDown
、
onPaste
等)。在脚本拥有的元素上的相同监听器仍然有效。
经验法则: 如果你要接触
window.X
、
document.X
、
navigator.X
、
history.X
或任何存储全局变量,请停下来并选择上面提供的替代方案。提供的接口(React + React DOM + SDK 组件 + 数据 hooks +
useAction
+ DataAppRouter +
DateRangePopover
+
copy
)涵盖了所有常规需求;之外的任何内容都是故意不可达的。

Rendering a chart: Metabase first

渲染图表:优先使用 Metabase

This is a per-element decision, not one taken once for a row, a section, or the app. One tile that needs custom code makes a row with one custom tile in it, not a row of them — the rest stay SDK components and keep their formatting, theming and drill-through. Consistent heights and card chrome come from the container each tile sits in, so "the others should match it" is not a reason to hand-build the others.
A built-in visualization carries the instance's theming, accessibility, tooltips, formatting and drill-through. A chart built in React carries none of that, and drifts from every other chart in the app as soon as either changes. So the question is never "which looks closer to my design" — it is can Metabase display this at all?
  • Yes →
    useMetabaseQueryObject
    +
    StaticQuestion
    /
    InteractiveQuestion
    .
    Bar, line, area, combo, row, trend, pie/donut, scalar/smartscalar, gauge, progress, funnel, scatter, waterfall, boxplot, sankey, pivot, map, object/list views, sortable table, and anything else in the chart-type list. Also whenever visualization settings can carry the presentation — axes, labels, stacking, goals, trendlines, split panels, series, formatting, table/pie/pivot/list settings — or the user benefits from sorting, column inspection, drill-through, or downloading. Build the semantic query from generated schema objects, destructure the returned
    query
    , and pass only that value in a card object —
    <StaticQuestion card={{ query }} visualization="pie" ... />
    . Never pass the whole
    { query, error, isLoading }
    hook result as
    card.query
    .
  • No →
    useMetabaseQuery
    and your own component.
    Only when the user asked for a custom visualization, or nothing Metabase renders can express the request: bespoke scorecards, alert panels, narrative layouts or mixed-content cards, custom interactions its chart/table chrome cannot express, unusual forms such as calendar grids, timelines, heat strips, radial views, custom maps or domain-specific diagrams, and elements combining several queries into one visual unit. Keep the row handling typed. Use a charting dependency the app already has; otherwise SVG is fine.
StaticQuestion
is the default of the two.
It renders the visualization and nothing else — no query bar, no editor, no save — and takes
withChartTypeSelector
,
withDownloads
,
title
and the sizing props. Use
InteractiveQuestion
when the element is meant to be explored: drill-through, filtering, switching the chart type, or the notebook editor. It shows a Save button unless you turn it off —
isSaveEnabled
defaults to
true
— which offers viewers a save-to-collection flow that belongs in Metabase, not in an app, so pass
isSaveEnabled={false}
unless the app is deliberately an editing surface. That applies to the default layout; giving
InteractiveQuestion
its own children replaces the layout, so a composed
<InteractiveQuestion.QuestionVisualization />
renders no toolbar and no Save button.
Pass
title={false}
when the question sits in a card or section that already carries a heading.
InteractiveQuestion
's default layout shows the question's own title, so you get two;
StaticQuestion
hides it already.
"It has to match our styling" is not a reason to hand-build one: pass
visualization
for the chart type,
visualizationSettings
for setting-level changes, and theme the SDK for the rest. A pie chart in a data app is a Metabase pie chart — unless the user asked for a custom one, which is their call to make, not one to infer from a design reference, brand colours, or a screenshot of something bespoke.
A single-value KPI is a scalar — or smartscalar, gauge, progress. If the tile wants a number plus something Metabase does not draw, such as a star row, a caption, or a total from a second query, render the scalar for the number and put the extra beside it. Hand-build it only when the number itself cannot come from one.
Always render a spinner (or skeleton) while
isLoading
is
true
— never an empty slot or stale value, which causes layout shift when the data arrives. Same rule for lifted / derived queries (pass
isLoading
down) and for
useAction
's
isExecuting
(spinner in the button +
disabled={isExecuting}
).
For the hook contract itself — generics, table sources, segments, measures, breakouts, sorting, and debugging — use skill discovery before authoring schema-backed data-layer code.
这是针对每个元素的决定,而不是针对一行、一个部分或整个应用的一次性决定。一个需要自定义代码的图块只会让一行中有一个自定义图块,而不是一整行都是——其余的保持为 SDK 组件,保留它们的格式、主题和钻取功能。一致的高度和卡片外观来自每个图块所在的容器,因此“其他的应该和它匹配”不是手动构建其他图块的理由。
内置的可视化带有实例的主题、可访问性、工具提示、格式和钻取功能。用 React 构建的图表没有这些,而且只要任何一方发生变化,就会与应用中的其他所有图表产生差异。因此问题从来不是“哪个看起来更接近我的设计”——而是 Metabase 到底能不能显示这个?
  • 能 →
    useMetabaseQueryObject
    +
    StaticQuestion
    /
    InteractiveQuestion
    。
    条形图、折线图、面积图、组合图、行图、趋势图、饼图/环形图、标量/智能标量、仪表盘、进度图、漏斗图、散点图、瀑布图、箱线图、桑基图、透视表、地图、对象/列表视图、可排序表格,以及图表类型列表中的任何其他内容。此外,只要可视化设置能够承载呈现需求——坐标轴、标签、堆叠、目标、趋势线、拆分面板、系列、格式、表格/饼图/透视/列表设置——或者用户能从排序、列检查、钻取或下载中受益,都应该使用。从生成的 schema 对象构建语义查询,解构返回的
    query
    ,并仅将该值传递到卡片对象中——
    <StaticQuestion card={{ query }} visualization="pie" ... />
    。永远不要将整个
    { query, error, isLoading }
    hook 结果作为
    card.query
    传递。
  • 不能 →
    useMetabaseQuery
    和你自己的组件。
    仅当用户要求自定义可视化,或者 Metabase 渲染的任何内容都无法表达需求时才使用:定制的记分卡、警报面板、叙述性布局或混合内容卡片、其图表/表格外壳无法表达的自定义交互、不常见的形式如日历网格、时间线、热条、径向视图、自定义地图或特定领域的图表,以及将多个查询组合成一个视觉单元的元素。保持行处理的类型化。使用应用已有的图表依赖;否则用 SVG 也可以。
StaticQuestion
是两者中的默认选择。
它只渲染可视化,没有其他内容——没有查询栏,没有编辑器,没有保存——并接受
withChartTypeSelector
、
withDownloads
、
title
和尺寸 props。当元素旨在用于探索时使用
InteractiveQuestion
:钻取、筛选、切换图表类型或笔记本编辑器。除非你关闭它,否则它会显示一个保存按钮——
isSaveEnabled
默认为
true
——这为查看者提供了保存到集合的流程,这属于 Metabase 的功能,而不是应用的功能,因此除非应用故意是一个编辑界面,否则传递
isSaveEnabled={false}
。这适用于默认布局;给
InteractiveQuestion
提供自己的子元素会替换布局,因此组合的
<InteractiveQuestion.QuestionVisualization />
不会渲染工具栏和保存按钮。
当问题位于已经有标题的卡片或部分中时,传递
title={false}
。
InteractiveQuestion
的默认布局会显示问题自己的标题,因此你会得到两个标题;
StaticQuestion
已经隐藏了它。
“它必须匹配我们的样式”不是手动构建的理由:传递
visualization
指定图表类型,传递
visualizationSettings
进行设置级别的更改,其余的通过 SDK 主题设置。数据应用中的饼图就是 Metabase 饼图——除非用户要求自定义,这是他们的决定,而不是从设计参考、品牌颜色或某个定制截图中推断出来的。
单值 KPI 是标量——或者智能标量、仪表盘、进度图。如果图块需要一个数字加上 Metabase 不会绘制的内容,比如星级行、说明文字或来自第二个查询的总数,那么用标量渲染数字,然后把额外的内容放在旁边。只有当数字本身不能来自 Metabase 时,才手动构建。
当
isLoading
为
true
时,始终渲染加载指示器(或骨架屏)
——永远不要留空槽或显示过时的值,这会在数据到达时导致布局偏移。对于提升/派生的查询(向下传递
isLoading
)和
useAction
的
isExecuting
(按钮中的加载指示器 +
disabled={isExecuting}
)也是同样的规则。
关于 hook 契约本身——泛型、表源、段、度量、分组、排序和调试——在编写基于 schema 的数据层代码之前使用技能发现。

App layout — fill the frame height

应用布局——填满框架高度

Metabase and
npm run dev
both give the bundle a full-height frame, but nothing between it and your root element sets a height — so an app shorter than the frame ends where its content ends, leaving its background, sidebar and footer in a short strip over bare white.
So the app's outermost element carries
minHeight: "100vh"
(plus
boxSizing: "border-box"
when it has padding) and paints the page background.
minHeight
, never
height
— taller content must still scroll. Regions that should reach the bottom (sidebar, sticky footer) go in a flex-column root with
flex: 1
. Check the emptiest screen — loading, empty state, a lone KPI — those are the ones that render short.
Metabase 和
npm run dev
都为包提供了全高框架,但在框架和你的根元素之间没有任何东西设置高度——因此比框架短的应用会在内容结束的地方结束,导致其背景、侧边栏和页脚只在空白的白色背景上形成一个短条。
因此应用的最外层元素应设置
minHeight: "100vh"
(如果有内边距,还要加上
boxSizing: "border-box"
)并绘制页面背景。用
minHeight
,永远不要用
height
——更高的内容必须仍然可以滚动。应该延伸到底部的区域(侧边栏、粘性页脚)放在 flex 列布局的根元素中,并设置
flex: 1
。检查最空的屏幕——加载中、空状态、单个 KPI——这些是渲染得最短的情况。

SDK component sizing

SDK 组件尺寸

SDK components do NOT auto-fit their parent — without
height
/
width
they render at their intrinsic size and overflow. Setting only the outer container or card height is not enough either; the height goes on the SDK component that owns the visualization:
  • Chart only: pass
    height
    to
    InteractiveQuestion.QuestionVisualization
    .
  • Default question layout with query bar: pass
    height
    to
    InteractiveQuestion
    .
  • Static question: pass
    height
    to
    StaticQuestion
    .
Use the actual body height available to the chart. For example, if a card is 560px tall and has a 60px header, pass
height="500px"
.
tsx
<div style={{ height: 360 }}>
  <StaticQuestion
    questionId={1}
    height="100%"
    width="100%"
    withChartTypeSelector={false}
  />
</div>
Do not wrap
InteractiveQuestion
or
StaticQuestion
in containers that clip or move on hover. Avoid
overflow: hidden
, hover transforms, and hover-driven layout shifts around embedded Metabase UI; popovers, menus, and chart tooltips need stable geometry and visible overflow.
SDK 组件不会自动适应其父容器——如果没有
height
/
width
,它们会以固有大小渲染并溢出。仅设置外部容器或卡片的高度也不够;高度要设置在拥有可视化的 SDK 组件上:
  • 仅图表:将
    height
    传递给
    InteractiveQuestion.QuestionVisualization
    。
  • 带查询栏的默认问题布局:将
    height
    传递给
    InteractiveQuestion
    。
  • 静态问题:将
    height
    传递给
    StaticQuestion
    。
使用图表可用的实际主体高度。例如,如果卡片高 560px,头部高 60px,则传递
height="500px"
。
tsx
<div style={{ height: 360 }}>
  <StaticQuestion
    questionId={1}
    height="100%"
    width="100%"
    withChartTypeSelector={false}
  />
</div>
不要将
InteractiveQuestion
或
StaticQuestion
包装在悬停时会裁剪或移动的容器中。避免在嵌入的 Metabase UI 周围使用
overflow: hidden
、悬停变换和悬停驱动的布局偏移;弹出框、菜单和图表工具提示需要稳定的几何位置和可见的溢出。

Sync to Metabase

同步到 Metabase

Data apps are delivered by Git, not uploaded — you commit the app directory and Metabase pulls it on its next remote-sync import.
  1. npm run build
    → produces the bundle at your
    data_app.yaml
    path
    (the template builds to
    dist/index.js
    ).
  2. From the repo root, commit the app directory — its
    data_app.yaml
    , the built bundle (the file
    path
    points at), the source, and the lockfile — and push:
    bash
    git add data_apps/<slug>
    git commit -m "Add <slug> data app"
    git push
  3. The app appears in Metabase on the next remote-sync import — a manual Pull changes (Admin → Data apps / Remote sync), the auto-import poll, or a restart — reachable at
    /apps/<slug>
    .
Don't offer to "deploy" the app or ask how the bundle reaches a staging environment — there is no separate deploy step, and the question only confuses users: Metabase imports the committed bundle straight from the connected repo on its next sync. Once the change is on the branch Metabase syncs from — however the user gets it there (a merged PR, or a push straight to that branch) — just tell them to pull it in and open the app in Metabase at
/apps/<slug>
.
  • To update: commit a new build and pull again.
  • To remove: delete the app's directory from the repo and push — the next sync removes it. You can't delete a repo-managed app from the UI; the Remove action on the Data apps admin page appears only after the repo is disconnected, to clear out apps left behind.
数据应用通过 Git 交付,而非上传——你提交应用目录,Metabase 在下次远程同步导入时拉取它。
  1. npm run build
    → 在你的
    data_app.yaml
    的
    path
    处生成包(模板构建到
    dist/index.js
    )。
  2. 从仓库根目录提交应用目录——包括
    data_app.yaml
    、构建后的包(
    path
    指向的文件)、源码和 lockfile——并推送:
    bash
    git add data_apps/<slug>
    git commit -m "Add <slug> data app"
    git push
  3. 应用会在下次远程同步导入时出现在 Metabase 中——手动拉取更改(Admin → Data apps / Remote sync)、自动导入轮询或重启——可通过
    /apps/<slug>
    访问。
不要主动提出“部署”应用,也不要询问包如何到达预发布环境——没有单独的部署步骤,这个问题只会让用户困惑:Metabase 在下次同步时直接从连接的仓库导入已提交的包。一旦更改出现在 Metabase 同步的分支上——不管用户是怎么弄过去的(合并 PR,或直接推送到该分支)——只需告诉他们拉取更改,然后在 Metabase 中通过
/apps/<slug>
打开应用即可。
  • 更新: 提交新的构建并再次拉取。
  • 删除: 从仓库中删除应用目录并推送——下次同步会删除它。你无法从 UI 中删除仓库管理的应用;只有在断开仓库连接后,数据应用管理页面上的删除操作才会出现,用于清理遗留的应用。

Common pitfalls

常见陷阱

SymptomFix
"Failed to fetch the user, the session might be invalid."Bad API key or CORS — check `( ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; [ -n "$ROOT" ] && source "$ROOT/.env.local" 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" ] && curl -H "x-api-key: $DATA_APP_MB_API_KEY" "$DATA_APP_MB_URL/api/user/current"
Invisible chart labels.Set
text-primary
in the theme (see Theme rules).
A chart looks unlike the rest of the instance, ignores the theme, or has no tooltips, formatting or drill-through.It was built in React. Render it with
StaticQuestion
/
InteractiveQuestion
and a
visualization
instead (see Rendering a chart: Metabase first).
Chart overflows its container.Pass
height
/
width
to the SDK component (see SDK component sizing).
App background stops partway down, bare white below short content.Give the root
minHeight: 100vh
(see App layout).
"Invalid hook call" at runtime.Two React copies.
dataAppConfig()
externalizes
react
— ensure
react
/
react-dom
are installed and you haven't added a second React or a mismatched version.
Bundle is multi-MB.React/the SDK should be externalized by the contract plugin — confirm
vite.config.ts
still uses
dataAppConfig()
and the pinned data-apps SDK tag is installed. (A large but not multi-MB bundle can also be inlined assets — see the single-file note above.)
dist/index.js
doesn't assign to
__dataAppFactory__
.
src/index.tsx
must
export default
the
DataAppFactory
— the preset wires that into the IIFE global.
Cannot find module '@metabase/embedding-sdk-react'
.
Run
npm install
(or the equivalent for your package manager). Types come from the package directly.
Property 'definedWithDefineQuery' is missing
/
Property 'definedWithDefineAction' is missing
on a hook call.
The hook got an inline object, a
satisfies
-typed object, or a spread copy instead of a definition. Export it with
defineQuery
from root-level
queries/
(or
defineAction
from
actions/
) and pass the import. Do not cast, and do not wrap the object in
defineQuery(...)
at the call site: that compiles but is never synchronized.
Drill popups don't open / SDK components show empty / "MetabaseProvider not found" at runtime in dev.
App.tsx
is rendering its own
<MetabaseProvider>
— remove it. The dev entry (SDK) and the production host provide the provider; wrapping it inside the bundle routes the SDK's state paths through the sandbox and breaks them.
Dev preview blank /
Bundle did not assign a function to __dataAppFactory__
/ sandbox errors in dev.
src/index.tsx
isn't default-exporting the factory, or your app code throws while the sandbox evaluates the bundle. Read the real error from the diagnostics feed (
curl -s "http://localhost:5174/__data-app/diagnostics?startEventId=0"
), or the dev toolbar's Diagnostics panel.
A network call works in
npm run dev
but is blocked after syncing to Metabase.
It was never allowed — you edited
allowed_hosts
without restarting the dev server, so the running sandbox and CSP still use the boot-time list. Restart
npm run dev
; the feed's
manifest
section flags this as
restartRequired
.
The SDK was rebuilt or reinstalled but the preview still behaves like the old version.Vite pre-bundles the SDK into
node_modules/.vite
and keys that cache on the package version, so an in-place rebuild doesn't invalidate it. Stop the dev server, delete the
node_modules/.vite
directory, and start it again (or run
npx vite --force
).
Component state resets on every edit.Expected: dev rebuilds the bundle and does a soft reload — re-evaluates it in the sandbox and remounts the app (auth/SDK stay loaded, no browser refresh). There's no module-level HMR / Fast Refresh because the app is an evaluated bundle in an isolated realm, so local component state resets.
URL changes but UI doesn't update in production (works in dev).Import the routing primitives from
@metabase/embedding-sdk-react/data-app
(not the main entry) and keep using
dataAppConfig()
— it externalizes
/data-app
so its routing isn't inlined (inlining triggers the React-state-batching-through-Near-Membrane bug).
症状修复方法
“Failed to fetch the user, the session might be invalid.”API 密钥或 CORS 有问题——检查 `( ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; [ -n "$ROOT" ] && source "$ROOT/.env.local" 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" ] && curl -H "x-api-key: $DATA_APP_MB_API_KEY" "$DATA_APP_MB_URL/api/user/current"
图表标签不可见。在主题中设置
text-primary
(见主题规则)。
图表看起来与实例的其他部分不同、忽略主题,或者没有工具提示、格式或钻取功能。它是用 React 构建的。改用
StaticQuestion
/
InteractiveQuestion
和
visualization
渲染(见渲染图表:优先使用 Metabase)。
图表溢出其容器。将
height
/
width
传递给 SDK 组件(见SDK 组件尺寸)。
应用背景中途停止,短内容下方是空白白色。给根元素设置
minHeight: 100vh
(见应用布局)。
运行时出现“Invalid hook call”。有两个 React 副本。
dataAppConfig()
会将
react
外部化——确保已安装
react
/
react-dom
,并且你没有添加第二个 React 或版本不匹配的 React。
包大小达到数 MB。React/SDK 应该被契约插件外部化——确认
vite.config.ts
仍然使用
dataAppConfig()
,并且已安装固定的 data-apps SDK 标签。(较大但未达到数 MB 的包也可能是内联的资源——见上文单文件说明。)
dist/index.js
没有赋值给
__dataAppFactory__
。
src/index.tsx
必须
export default
DataAppFactory
——预设会将其连接到 IIFE 全局变量。
Cannot find module '@metabase/embedding-sdk-react'
。
运行
npm install
(或你的包管理器的等效命令)。类型直接来自该包。
hook 调用时出现
Property 'definedWithDefineQuery' is missing
/
Property 'definedWithDefineAction' is missing
。
hook 收到的是内联对象、
satisfies
类型的对象或展开的副本,而不是定义。从根级
queries/
用
defineQuery
导出(或从
actions/
用
defineAction
导出)并传递导入。不要使用类型断言,也不要在调用处将对象包装在
defineQuery(...)
中:这能通过编译但永远不会被同步。
钻取弹窗无法打开 / SDK 组件显示为空 / 开发环境运行时出现“MetabaseProvider not found”。
App.tsx
正在渲染它自己的
<MetabaseProvider>
——删除它。开发入口(SDK)和生产宿主提供 provider;在包内部包装会导致 SDK 的状态路径穿过沙箱并破坏它们。
开发预览空白 /
Bundle did not assign a function to __dataAppFactory__
/ 开发环境出现沙箱错误。
src/index.tsx
没有默认导出工厂函数,或者你的应用代码在沙箱评估包时抛出错误。从诊断信息流(
curl -s "http://localhost:5174/__data-app/diagnostics?startEventId=0"
)或开发工具栏的诊断面板读取真实错误。
网络调用在
npm run dev
中有效,但同步到 Metabase 后被阻止。
它从未被允许——你编辑了
allowed_hosts
但没有重启开发服务器,因此运行中的沙箱和 CSP 仍然使用启动时的列表。重启
npm run dev
;信息流的
manifest
部分会将此标记为
restartRequired
。
SDK 已重新构建或重新安装,但预览仍然表现得像旧版本。Vite 将 SDK 预打包到
node_modules/.vite
中,并且该缓存以包版本为键,因此原地重新构建不会使缓存失效。停止开发服务器,删除
node_modules/.vite
目录,然后重新启动(或运行
npx vite --force
)。
每次编辑后组件状态都会重置。预期行为:开发环境会重新构建包并执行软重载——在沙箱中重新评估它并重新挂载应用(认证/SDK 保持加载状态,无需浏览器刷新)。没有模块级 HMR / 快速刷新,因为应用是在隔离领域中评估的包,因此本地组件状态会重置。
生产环境中 URL 变化但 UI 不更新(开发环境正常)。从
@metabase/embedding-sdk-react/data-app
(而不是主入口)导入路由原语,并继续使用
dataAppConfig()
——它会将
/data-app
外部化,因此其路由不会被内联(内联会触发通过 Near Membrane 的 React 状态批处理 bug)。