/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.
$ npx -y skills add metabase/metabase --skill metabase-data-app-routing --agent claude-codeHow 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.mdname: 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
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
Metabase is the easy, open-source way for everyone in your company to ask questions and learn from data.
Repo: metabase/metabase
Other skills on metabase.
- /add-malli-schemas
Efficiently add Malli schemas to API endpoints in the Metabase codebase with proper patterns, validation timing, and error handling
Open skill - /add-tracing
Add OpenTelemetry tracing spans to Clojure code following Metabase tracing conventions. Use when instrumenting backend code with trace coverage.
Open skill - /analytics-events
Add product analytics events to track user interactions in the Metabase frontend
Open skill - /clojure-eval
Evaluate Clojure code via nREPL using clj-nrepl-eval. Use this when you need to test code, check if edited files compile, verify function behavior, or interact with a running REPL session.
Open skill - /clojure-review
Review Clojure and ClojureScript code changes for compliance with Metabase coding standards, style violations, and code quality issues. Use when reviewing pull requests or diffs containing Clojure/ClojureScript code.
Open skill - /clojure-write
Guide Clojure and ClojureScript development using REPL-driven workflow, coding conventions, and best practices. Use when writing, developing, or refactoring Clojure/ClojureScript code.
Open skill

