metabase-data-app-routing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Add routing to a data-app

为 data-app 添加路由

A Metabase data-app bundle doesn't bundle a router library — it imports three small primitives from
@metabase/embedding-sdk-react/data-app
:
APIPurpose
<DataAppRouter>
Wrap the app once. Tracks the current sub-path. Auto-detects the iframe's URL prefix; bundle author writes no basename.
<DataAppLink to="/customers/42">
Internal navigation link. Renders a real
<a href>
so middle-click / cmd-click open in a new tab.
useDataAppLocation()
Returns
{ pathname, navigate }
. Use
pathname
for match-by-equality /
startsWith
rendering; use
navigate(to)
for programmatic nav.
That's the entire surface. No
react-router
of any version, no
<BrowserRouter>
, no
<HashRouter>
.
The API is deliberately decoupled from any router library so a future Metabase version can swap the underlying implementation without touching bundle code.
Metabase data-app 包不内置路由库——它从
@metabase/embedding-sdk-react/data-app
导入三个轻量原语:
API用途
<DataAppRouter>
在应用外层包裹一次。跟踪当前子路径。自动检测 iframe 的 URL 前缀;包开发者无需编写 basename。
<DataAppLink to="/customers/42">
内部导航链接。渲染为真实的
<a href>
元素,因此中键点击/Command 点击可以在新标签页打开。
useDataAppLocation()
返回
{ pathname, navigate }
。使用
pathname
进行全等匹配/
startsWith
匹配来渲染内容;使用
navigate(to)
进行编程式导航。
以上就是全部 API 表面。没有任何版本的
react-router
,没有
<BrowserRouter>
,也没有
<HashRouter>
。
该 API 特意与所有路由库解耦,以便未来 Metabase 版本可以替换底层实现,而无需修改包的代码。

When to use this skill

何时使用此技能

  • The user has a working data-app project — scaffolded from the
    data-app-template
    repo, so a one-liner
    vite.config.ts
    (
    dataAppConfig()
    ) and an
    src/index.tsx
    that exports a factory already exist. The dev preview has no in-project entry: the SDK's dev preset serves it.
  • The user wants the bundle to render different content at different URLs (
    /overview
    ,
    /customers/:id
    ).
  • Do not use this skill to scaffold a project from scratch — it only patches an existing data-app project. If there is no project yet, stop and tell the user to start with a new data-app scaffold before adding routing.
The template already externalizes
@metabase/embedding-sdk-react/data-app
in
vite.config.ts
. You do NOT need to edit
vite.config.ts
to add routing — just edit
src/App.tsx
(and add more component files as needed) per the step below.
  • 用户已有一个可运行的 data-app 项目——基于
    data-app-template
    仓库搭建,因此已存在一行式的
    vite.config.ts
    (
    dataAppConfig()
    )和导出工厂函数的
    src/index.tsx
    。开发预览没有项目内的入口:由 SDK 的开发预设提供服务。
  • 用户希望包在不同 URL(
    /overview
    、
    /customers/:id
    )下渲染不同内容。
  • 不要使用此技能 从零搭建项目——它仅用于修补现有 data-app 项目。如果还没有项目,请停止并告知用户先使用新的 data-app 脚手架创建项目,再添加路由。
模板已经在
vite.config.ts
中将
@metabase/embedding-sdk-react/data-app
设为外部依赖。你无需编辑
vite.config.ts
来添加路由——只需按照以下步骤编辑
src/App.tsx
(并根据需要添加更多组件文件)即可。

Step 1 — Wrap
App.tsx
with
<DataAppRouter>

步骤 1 —— 用
<DataAppRouter>
包裹
App.tsx

Import the routing primitives normally.
<DataAppRouter>
does NOT take a
basename
prop — it auto-detects the iframe's URL prefix (
/embed/apps/<name>
) in production and resolves to no prefix in the Vite dev preview.
App.tsx
is pure content — no
<MetabaseProvider>
here. The SDK's dev preview entry (
DataAppDevProvider
) and the production host (
DataAppProvider
) each wrap the tree in their own realm.
tsx
import { StaticQuestion } from "@metabase/embedding-sdk-react";
import {
  DataAppRouter,
  DataAppLink,
  useDataAppLocation,
} from "@metabase/embedding-sdk-react/data-app";

function Nav() {
  return (
    <nav style={{ padding: 16, borderBottom: "1px solid #e5e7eb" }}>
      <DataAppLink to="/" style={{ marginRight: 16 }}>Overview</DataAppLink>
      <DataAppLink to="/customers/42">Customer 42</DataAppLink>
    </nav>
  );
}

function Page() {
  const { pathname } = useDataAppLocation();

  if (pathname === "/") {
    return (
      <div style={{ padding: 24 }}>
        <h1>Overview</h1>
        <StaticQuestion questionId={1} height={360} />
      </div>
    );
  }

  const customerMatch = pathname.match(/^\/customers\/(\d+)$/);
  if (customerMatch) {
    const id = Number(customerMatch[1]);
    return (
      <div style={{ padding: 24 }}>
        <h1>Customer #{id}</h1>
        <StaticQuestion questionId={id} height={360} />
      </div>
    );
  }

  return <div style={{ padding: 24 }}>Not found: {pathname}</div>;
}

export default function App() {
  return (
    <DataAppRouter>
      <Nav />
      <Page />
    </DataAppRouter>
  );
}
Run
yarn dev
, click the links, watch the URL bar change. Reload at
http://localhost:5174/customers/42
and the dev preview lands on the customer route directly.
正常导入路由原语。
<DataAppRouter>
不需要 传入
basename
属性——它在生产环境会自动检测 iframe 的 URL 前缀(
/embed/apps/<name>
),在 Vite 开发预览中则解析为无前缀。
App.tsx
是纯内容组件——这里不需要
<MetabaseProvider>
。SDK 的开发预览入口(
DataAppDevProvider
)和生产环境宿主(
DataAppProvider
)会在各自的运行环境中包裹组件树。
tsx
import { StaticQuestion } from "@metabase/embedding-sdk-react";
import {
  DataAppRouter,
  DataAppLink,
  useDataAppLocation,
} from "@metabase/embedding-sdk-react/data-app";

function Nav() {
  return (
    <nav style={{ padding: 16, borderBottom: "1px solid #e5e7eb" }}>
      <DataAppLink to="/" style={{ marginRight: 16 }}>Overview</DataAppLink>
      <DataAppLink to="/customers/42">Customer 42</DataAppLink>
    </nav>
  );
}

function Page() {
  const { pathname } = useDataAppLocation();

  if (pathname === "/") {
    return (
      <div style={{ padding: 24 }}>
        <h1>Overview</h1>
        <StaticQuestion questionId={1} height={360} />
      </div>
    );
  }

  const customerMatch = pathname.match(/^\/customers\/(\d+)$/);
  if (customerMatch) {
    const id = Number(customerMatch[1]);
    return (
      <div style={{ padding: 24 }}>
        <h1>Customer #{id}</h1>
        <StaticQuestion questionId={id} height={360} />
      </div>
    );
  }

  return <div style={{ padding: 24 }}>Not found: {pathname}</div>;
}

export default function App() {
  return (
    <DataAppRouter>
      <Nav />
      <Page />
    </DataAppRouter>
  );
}
运行
yarn dev
,点击链接,观察地址栏变化。在
http://localhost:5174/customers/42
页面刷新,开发预览会直接加载客户路由。

Always preselect the default (leftmost) tab on load

加载时始终预选默认(最左侧)标签页

If the app presents multiple tabs (or any top-level page switcher), the base path
/
MUST render the default — leftmost / first — tab's content, never a blank page, a "Not found", or an empty shell.
This is the single most common mistake: the app boots at
/
, no branch matches, and the user sees nothing until they click a tab. Don't rely on the user (or a later navigation) to select the first tab — the default tab is the initial state.
Two equivalent ways to guarantee it, depending on whether tabs are route-backed:
  • Route-backed tabs — give
    /
    an explicit branch that renders the first tab, so an unmatched/base path resolves to it:
    tsx
    const TABS = [
      { path: "/", label: "Overview", render: () => <Overview /> },
      { path: "/customers", label: "Customers", render: () => <Customers /> },
      { path: "/reports", label: "Reports", render: () => <Reports /> },
    ];
    
    function Page() {
      const { pathname } = useDataAppLocation();
      // Exact-or-prefix match; fall back to the FIRST tab so `/` (and any
      // unknown sub-path) always shows the default tab, never a blank page.
      const active =
        TABS.find((t) => t.path !== "/" && pathname.startsWith(t.path)) ?? TABS[0];
      return active.render();
    }
  • Local-state tabs (no routing) — initialize the active-tab state to the first tab, so the very first render shows it:
    tsx
    const [active, setActive] = useState(TABS[0].id); // default = leftmost tab
Verify by reloading the app at its base path (
/
) with a fresh load: the leftmost tab's content must be visible immediately, and that tab must read as selected in the tab bar.
如果应用呈现多个标签页(或任何顶层页面切换器),基础路径
/
必须渲染默认的——最左侧/第一个——标签页的内容,绝不能是空白页、“未找到”或空壳。
这是最常见的错误:应用在
/
路径启动时,没有分支匹配,用户什么都看不到,直到点击某个标签页。不要依赖用户(或后续导航)来选择第一个标签页——默认标签页就是初始状态。
有两种等效的保证方式,取决于标签页是否基于路由:
  • 基于路由的标签页 —— 给
    /
    添加一个显式分支来渲染第一个标签页,这样未匹配的路径/基础路径都会回退到它:
    tsx
    const TABS = [
      { path: "/", label: "Overview", render: () => <Overview /> },
      { path: "/customers", label: "Customers", render: () => <Customers /> },
      { path: "/reports", label: "Reports", render: () => <Reports /> },
    ];
    
    function Page() {
      const { pathname } = useDataAppLocation();
      // Exact-or-prefix match; fall back to the FIRST tab so `/` (and any
      // unknown sub-path) always shows the default tab, never a blank page.
      const active =
        TABS.find((t) => t.path !== "/" && pathname.startsWith(t.path)) ?? TABS[0];
      return active.render();
    }
  • 本地状态标签页(无路由) —— 将活动标签页状态初始化为第一个标签页,这样首次渲染就会显示它:
    tsx
    const [active, setActive] = useState(TABS[0].id); // default = leftmost tab
通过在基础路径(
/
)重新加载应用来验证:最左侧标签页的内容必须立即可见,并且该标签页在标签栏中应显示为选中状态。

How navigation translates to the parent URL

导航如何映射到父级 URL

You don't need to do anything for this. For context:
  • Bundle calls
    <DataAppLink to="/customers/42">
    → host's
    navigate
    runs
    pushState
    with
    /embed/apps/<name>/customers/42
    .
  • The parent's
    AppView
    observes the iframe URL change and mirrors it to the parent's URL bar as
    /apps/<name>/customers/42
    — the
    /embed
    prefix is stripped because the parent's React Router route is
    /apps/:name/*
    , not
    /embed/...
    .
  • Reload works because the BE serves the same
    data-app.html
    for every
    /embed/apps/:name/*
    sub-path; the iframe boots,
    <DataAppRouter>
    reads
    window.location.pathname
    , auto-detects the prefix, and starts at the right sub-path.
你不需要为此做任何操作。供参考:
  • 包调用
    <DataAppLink to="/customers/42">
    → 宿主的
    navigate
    会执行
    pushState
    ,路径为
    /embed/apps/<name>/customers/42
    。
  • 父级的
    AppView
    监听 iframe 的 URL 变化,并将其同步到父级的地址栏,显示为
    /apps/<name>/customers/42
    ——
    /embed
    前缀被移除,因为父级的 React Router 路由是
    /apps/:name/*
    ,而非
    /embed/...
    。
  • 刷新功能之所以有效,是因为后端会为所有
    /embed/apps/:name/*
    子路径提供相同的
    data-app.html
    ;iframe 启动后,
    <DataAppRouter>
    读取
    window.location.pathname
    ,自动检测前缀,并从正确的子路径开始。

What NOT to do

禁止操作

  • Don't add
    react-router-dom
    (or any router library) to the project. The bundle doesn't need it.
  • Don't use
    <BrowserRouter>
    or
    <HashRouter>
    inside the bundle. They run their own
    setState
    flow that hits the Near Membrane batching bug.
  • Don't call
    window.history.pushState
    directly
    from inside the bundle. Use
    <DataAppLink>
    or
    navigate
    from
    useDataAppLocation
    .
  • Don't try to read or write the parent's URL. The iframe's sandbox attribute blocks it by design.
  • 不要添加
    react-router-dom
    (或任何路由库)到项目中。包不需要它。
  • 不要在包内使用
    <BrowserRouter>
    或
    <HashRouter>
    。它们自身的
    setState
    流程会触发 Near Membrane 批处理 bug。
  • 不要在包内直接调用
    window.history.pushState
    。请使用
    <DataAppLink>
    或
    useDataAppLocation
    返回的
    navigate
    。
  • 不要尝试读取或写入父级的 URL。iframe 的 sandbox 属性会按设计阻止此操作。

Common patterns

常见模式

Conditional render based on path

基于路径的条件渲染

tsx
const { pathname } = useDataAppLocation();
if (pathname === "/") return <Home />;
if (pathname.startsWith("/customers/")) return <Customer />;
return <NotFound />;
tsx
const { pathname } = useDataAppLocation();
if (pathname === "/") return <Home />;
if (pathname.startsWith("/customers/")) return <Customer />;
return <NotFound />;

Programmatic navigation (e.g. from a
<button>
)

编程式导航(例如从
<button>
触发)

tsx
function OpenCustomerButton({ id }: { id: number }) {
  const { navigate } = useDataAppLocation();
  return <button onClick={() => navigate(`/customers/${id}`)}>Open #{id}</button>;
}
tsx
function OpenCustomerButton({ id }: { id: number }) {
  const { navigate } = useDataAppLocation();
  return <button onClick={() => navigate(`/customers/${id}`)}>Open #{id}</button>;
}

Parsing path params manually

手动解析路径参数

There's no built-in
useParams
. Match yourself:
tsx
const { pathname } = useDataAppLocation();
const match = pathname.match(/^\/customers\/(\d+)$/);
if (match) {
  const id = Number(match[1]);
  // ...
}
If you find yourself doing this often, factor it into your own helper inside the bundle:
tsx
function useCustomerIdFromPath(): number | null {
  const { pathname } = useDataAppLocation();
  const m = pathname.match(/^\/customers\/(\d+)$/);
  return m ? Number(m[1]) : null;
}
没有内置的
useParams
。请自行匹配:
tsx
const { pathname } = useDataAppLocation();
const match = pathname.match(/^\/customers\/(\d+)$/);
if (match) {
  const id = Number(match[1]);
  // ...
}
如果你经常需要这样做,可以在包内封装成自己的辅助函数:
tsx
function useCustomerIdFromPath(): number | null {
  const { pathname } = useDataAppLocation();
  const m = pathname.match(/^\/customers\/(\d+)$/);
  return m ? Number(m[1]) : null;
}

Troubleshooting

故障排查

SymptomFix
<DataAppLink>
renders as plain text (no clickable link) on initial load.
The bundle hasn't finished loading and the fallback path is showing. The provider wrap is supplied automatically (the SDK dev preview's
DataAppDevProvider
, or the host's
DataAppProvider
) — don't add
<MetabaseProvider>
inside the app. The fallback resolves to a real link once the bundle is up.
URL changes in dev preview but the production iframe shows the bundle re-render itself on every navigation (or routes don't work in prod at all).
vite.config.ts
got edited and lost
@metabase/embedding-sdk-react/data-app
from
external
/
output.globals
. Restore from the template — without it, Vite inlines the package's implementation into
dist/index.js
, which runs inside the Near Membrane sandbox and breaks React's state batching.
URL changes but UI doesn't.A
<BrowserRouter>
/
<HashRouter>
is still in the tree. Strip the router library out and use
<DataAppRouter>
/
<DataAppLink>
instead — the Near Membrane interaction with React-18 batching breaks every router that runs its own
setState
inside the bundle.
Reload at a deep URL in dev (
localhost:5174/customers/42
) shows a blank page.
Vite's dev server is serving the route as a 404 instead of falling back to
index.html
. Set
appType: "spa"
in
vite.config.ts
(it's the default — only an issue if someone overrode it).
Middle-click on a
<DataAppLink>
does nothing.
The link uses
event.button !== 0
to skip non-left clicks; check you haven't wrapped it in another component that swallows the event.
症状修复方法
<DataAppLink>
初始加载时渲染为纯文本(没有可点击的链接)。
包尚未完成加载,当前显示的是回退路径。Provider 包裹是自动提供的(SDK 开发预览的
DataAppDevProvider
,或宿主的
DataAppProvider
)——不要在应用内添加
<MetabaseProvider>
。包加载完成后,回退内容会替换为真实链接。
开发预览中 URL 会变化,但生产环境 iframe 中每次导航都会导致包重新渲染(或路由在生产环境完全不工作)。
vite.config.ts
被修改,丢失了
external
/
output.globals
中的
@metabase/embedding-sdk-react/data-app
。请从模板恢复——没有它的话,Vite 会将该包的实现内联到
dist/index.js
中,运行在 Near Membrane 沙箱内时会破坏 React 的状态批处理。
URL 变化但 UI 没有更新。组件树中仍有
<BrowserRouter>
/
<HashRouter>
。请移除路由库,改用
<DataAppRouter>
/
<DataAppLink>
——Near Membrane 与 React 18 批处理的交互会破坏所有在包内运行自身
setState
的路由库。
在开发环境的深层 URL(
localhost:5174/customers/42
)刷新显示空白页。
Vite 的开发服务器将该路由视为 404,而没有回退到
index.html
。请在
vite.config.ts
中设置
appType: "spa"
(这是默认值——仅当有人覆盖了该配置时才会出现此问题)。
中键点击
<DataAppLink>
没有反应。
该链接使用
event.button !== 0
来跳过非左键点击;请检查你是否将其包裹在另一个会吞掉事件的组件中。