Skip to content
Development
Skill

/metabase-data-app-routing

Add client-side routing (multiple pages) to an existing Metabase data-app project using the host-provided `DataAppRouter`, `DataAppLink`, and `useDataAppLocation` primitives. Use when the user has an existing data-app project and wants more than one page.

From plugin
metabase
49k23 skills11 agents23 commands
Install
$ npx -y skills add metabase/metabase --skill metabase-data-app-routing --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/metabase-data-app-routing

Context preview

The summary Claude sees to decide when to auto-load this skill.

Add client-side routing (multiple pages) to an existing Metabase data-app project using the host-provided `DataAppRouter`, `DataAppLink`, and `useDataAppLocation` primitives. Use when the user has an existing data-app project and wants more than one page.

SKILL.md

metabase-data-app-routing.SKILL.md
name: metabase-data-app-routing
description: Add client-side routing (multiple pages) to an existing Metabase data-app project using the host-provided `DataAppRouter`, `DataAppLink`, and `useDataAppLocation` primitives. Use when the user has an existing data-app project and wants more than one page.

Add routing to a 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`:

| API | Purpose | |---|---| | `<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.

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.

Step 1 — Wrap `App.tsx` with `<DataAppRouter>`

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.

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.

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:
  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:
  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.

How navigation translates to the parent 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 beca
Read more
Ships withmetabase

Metabase is the easy, open-source way for everyone in your company to ask questions and learn from data.

Get the whole plugin