---
title: Embedding the dashboard
description: Mount the React dashboard, use single parts, authenticate across origins, style it with CSS variables and share a read-only link.
---

The dashboard is a React component that talks to the [admin routes](/reference/http-api#admin-routes). Its controls are built on Base UI, its animations on Motion and its charts on Recharts. These are optional peer dependencies of `analytik/dashboard`, so install them next to React when you embed it. Recharts loads lazily with the first chart. Light and dark follow `prefers-color-scheme`, or `[data-theme]` on an ancestor.

## Mount it

```sh
bun add analytik react react-dom @base-ui/react motion recharts
```

```tsx Admin.tsx
import { Dashboard } from 'analytik/dashboard'
import 'analytik/dashboard/styles.css'

export function Admin() {
  return <Dashboard api="/api" />
}
```

`api` is the base path of the server, the same value as `basePath` in `createAnalytik` (default `/api`). Without `siteId`, the dashboard shows a site switcher. Pass `siteId` to pin it to one site.

```tsx
<Dashboard api="/api" siteId="SITE_ID" />
```

Site tiles show each site's icon, which the analytik server fetches from the site and keeps (its `<link rel="icon">`, then its web manifest, then `/favicon.ico`). The browser asks only your server, never the site or a third-party icon service. Pass `favicons={false}` to show the site's initial instead, or a function that returns the icon URL for a site (`undefined` keeps the initial).

**Settings → General → Sync from website** reads the site's own home page and offers its name (`og:site_name`, else the title), icon and tile colour (`theme-color`), and says whether the page loads your tracker. Nothing changes until you tick the details to take over, and a name you typed yourself starts unticked. The server asks only public host names over https, follows at most three redirects (each one checked the same way), reads at most 200 kB of the page and 100 kB per icon, and skips generated test sites. A stored icon older than 30 days is fetched again the next time it is shown. When a website is added, the dashboard takes over its icon and colour, and its name if you left the name empty.

The **Add a website** dialog asks for the domain, a name, the time zone, the revenue currency and, optionally, how many days to keep visits and events. Once the site is saved, the dashboard opens its settings on **Tracking code**, with the snippet to paste into the site.

Under the dashboard, a small line links to its source and its author. Pass `credits={false}` to leave it out.

## Pausing and deleting a site

Settings → General has a **Tracking** card. **Pause tracking** drops new visits, events and server goals for the site until you resume; payments still come in, and nothing already stored changes. **Delete website** removes the site with everything recorded for it, after you type the site's domain and the admin password. With your own authentication (the server's `verify()` option) there is no password, so the domain alone confirms it. The same two actions are in the menu that appears on a site card in **All sites** when you hover it (always shown on touch screens). Paused sites carry a **Paused** badge there. **All sites** sorts the cards by the date they were added, name, domain, visitors, revenue or orders in the last 24 hours, with a button to reverse the order; the choice is saved with the other dashboard settings.

## Theme

Settings → Appearance sets the dashboard to light or dark. The default, **Same as the page**, follows a `data-theme="light"` or `data-theme="dark"` on the page (on `<html>`, say) and otherwise the system's setting, so a theme switch on your site carries over. The choice is saved with the other dashboard settings; the `theme` prop of `<Dashboard>` overrides it.

## Formatting

Settings → Formatting sets how the whole dashboard writes numbers, money, dates and times: digit grouping (the language's own, `1,234.5`, `1.234,5`, `1 234,5` or `1'234.5`), the currency as a symbol or a code, cents always or only when there are some, five date styles (`Oct 27, 2026`, `27 Oct 2026`, `27.10.2026`, `10/27/2026`, `2026-10-27`) and a 12- or 24-hour clock. Each option in the lists shows an example written its way. The choice is saved with the other dashboard settings, for every admin.

## Language

The dashboard ships in eighteen languages: English (`en`), Slovak (`sk`), Czech (`cs`), German (`de`), Spanish (`es`), French (`fr`), Italian (`it`), Dutch (`nl`), Portuguese (`pt`, Brazilian), Polish (`pl`), Russian (`ru`), Turkish (`tr`), Japanese (`ja`), Chinese (`zh`, Simplified), Korean (`ko`), Indonesian (`id`), Hindi (`hi`) and Vietnamese (`vi`). The list in the header and in Settings is sorted alphabetically by each language's own name. Right-to-left languages such as Arabic are not supported yet. The Language button in the header, beside the theme button, and Settings → Appearance → Language pick one, saved for every admin; **Browser language**, the default, follows the browser and falls back to English. The `locale` prop of `<Dashboard>` (`locale="sk"`) overrides it and hides the header button. Numbers, month names, weekday names, currency names and country names follow the language through `Intl`, and the digit grouping follows it too unless Settings → Formatting fixes one.

A language's texts load as their own small file (about 15 kB gzip each) the first time that language is used, so English costs nothing extra. Until it arrives the dashboard reads in English.

Change a text, or add a language, with the `messages` prop. Keys are the English texts and values their translation; `{name}` marks a value that is filled in, and a count is written as `{count, plural, one {# visitor} other {# visitors}}`.

```tsx
<Dashboard
  api="/api"
  locale="de"
  messages={{
    de: { Overview: 'Übersicht', Visitors: 'Besucher' },
    en: { Funnels: 'Journeys' }, // rename a label in English
  }}
/>
```

A text you leave out shows in English. The translations were written with machine help and are open to corrections: send them as a pull request to the file in `src/dashboard/locales/`. `bun run extract:messages` in the package lists every text the dashboard translates, and `analytik/dashboard` exports `LANGUAGES` and the `Messages` type.

The component includes a login form. It signs in with `POST /auth/login` and keeps the session cookie, so the dashboard and the API should share an origin, or the API must send CORS headers that allow credentials for your dashboard's origin.

## Exploring the data

The **Compare** control in the toolbar draws the period just before the range (or the same dates a year earlier) as a lighter dashed line and shows each breakdown row's change. The comparison comes back in the same requests as the main numbers, so there is no extra request, but each of those reads the data twice while it is on, so it is off by default and kept only for the session (and in the address with `urlState`).

**Segments** in the filter bar save the active filters under a name, apply them in one click (every block loads once) and rename or delete them. Up to 20 are kept, with the other dashboard settings, so they follow the admin to every browser and cost no extra request; with `syncPreferences={false}` they stay in the browser. With `urlState` a segment's id stands in for its filters in the address.

### Shortcuts and the command palette

Press <kbd>⌘ K</kbd> (<kbd>Ctrl K</kbd> elsewhere) to open a palette: type to go to a view, set a range, switch site, turn Live or the comparison on or off, apply a segment, or add a filter from a value a block on screen already shows. It makes no requests of its own, and its code is loaded the first time you open it.

| Keys                                | Does                     |
| ----------------------------------- | ------------------------ |
| <kbd>⌘ K</kbd> or <kbd>Ctrl K</kbd> | Open the command palette |
| <kbd>g</kbd> then <kbd>o</kbd>      | Go to Overview           |
| <kbd>g</kbd> then <kbd>c</kbd>      | Go to Acquisition        |
| <kbd>g</kbd> then <kbd>p</kbd>      | Go to Pages              |
| <kbd>g</kbd> then <kbd>e</kbd>      | Go to Events             |
| <kbd>g</kbd> then <kbd>f</kbd>      | Go to Funnels            |
| <kbd>g</kbd> then <kbd>r</kbd>      | Go to Revenue            |
| <kbd>g</kbd> then <kbd>s</kbd>      | Go to Sessions           |
| <kbd>g</kbd> then <kbd>l</kbd>      | Go to Live               |
| <kbd>g</kbd> then <kbd>t</kbd>      | Go to Settings           |
| <kbd>g</kbd> then <kbd>a</kbd>      | Go to All sites          |
| <kbd>[</kbd> and <kbd>]</kbd>       | Previous and next period |
| <kbd>r</kbd>                        | Refresh                  |
| <kbd>?</kbd>                        | List the shortcuts       |

The shortcuts listen on the dashboard, not on the page, so they never reach the host's own keys: they act while focus is inside the dashboard (or anywhere on the page when `urlState` says the dashboard owns it). They are ignored while you type in a field, with <kbd>Ctrl</kbd>, <kbd>Alt</kbd> or <kbd>⌘</kbd> held, and while a dialog or menu is open. Pass `shortcuts={false}` to turn them off, the palette included.

## Your own authentication

If your app already knows who the admin is, replace the login with a custom `admin.verify` on the server and a `fetcher` on the client:

```ts server
const analytik = createAnalytik({
  storage,
  admin: { verify: async (request) => (await getSession(request))?.role === 'admin' },
})
```

```tsx
<Dashboard
  api="https://analytics.example.com/api"
  fetcher={(input, init) => fetch(input, { ...init, headers: { ...init?.headers, Authorization: `Bearer ${token}` } })}
/>
```

`fetcher` has the signature of `fetch`. With a Bearer token the browser does not need cookies at all.

## Parts

Each view is exported on its own, so you can place a few in your own layout. Apart from `Overview`, the parts take no props. They read the client, the site, the date range and the filters from a context, so they must sit inside `<Dashboard>` or inside a `<DashboardProvider>`; rendered anywhere else they throw. `DashboardProvider` takes a client from `createApiClient`, the absolute API URL (used in snippets and webhook URLs), the `Site` to show and a `DateRange`:

```tsx
import {
  createApiClient,
  DashboardProvider,
  Overview,
  presetRange,
  Realtime,
  Revenue,
  Sources,
} from 'analytik/dashboard'
import type { Site } from 'analytik'

const client = createApiClient('/api')

export function Summary({ site }: { site: Site }) {
  return (
    <DashboardProvider
      client={client}
      apiUrl="https://analytics.example.com/api"
      site={site}
      range={presetRange('30d', Date.now())}
    >
      <Overview />
      <Realtime />
      <Sources />
      <Revenue />
    </DashboardProvider>
  )
}
```

Optional provider props are `filters`, `onFilter`, `onSiteChange`, `onViewChange` and `customizing` (opens the overview's layout editor). Get the `Site` with `client.site('SITE_ID')` or `client.sites()`.

The dashboard module exports these building blocks:

- Views: `Overview`, `Sources`, `Pages`, `Events`, `Funnels`, `Revenue`, `Sessions`, `Realtime`, `Settings`, plus `SitesOverview` (a card per site; takes `client`, `sites`, `onOpen` and `onAdd`, and optionally `onImport` to turn "Add website" into a menu with an import entry).
- Dialogs and forms: `AddSiteDialog` (takes `client`, `onCreated` and `onClose`), `CreateSiteForm`, `LoginForm`.
- Pieces of a view: `BreakdownCard`, `SessionTimeline`, `SiteSettings`, `PrivacySettings`, `SnippetGenerator`, `CampaignLinkBuilder`.
- Charts and widgets: `AreaChart`, `BarList`, `Sparkline`, `StatTile`, `DeltaBadge`, and `WorldMap` (takes `locations` from a `Realtime` result, an optional `globe`, and `selected` with `onSelect` to make the markers buttons).
- Toolbar controls: `RangePicker`, `IntervalPicker`, `FilterBar`.
- Helpers: `createApiClient`, `RequestError`, `queryParams`, `useDashboard`, and the range helpers `presetRange`, `customRange`, `shiftRange` and `RANGE_PRESETS`.

If you want your own components, `createApiClient(api, fetcher?)` returns a typed client for the admin routes, with the response types from [Types](/reference/types). `api` is the API base path as a string, and the optional second argument is a `fetch`-compatible function:

```ts
import { createApiClient } from 'analytik/dashboard'

const api = createApiClient('/api')
const overview = await api.overview('SITE_ID', { from: Date.now() - 7 * 86_400_000, to: Date.now() })
```

The client keeps each answer for a minute and shares a request with an identical one still on its way, so parts of a view that need the same numbers, and a view opened again soon after, don't ask the server twice. That matters on hosts that bill every request, such as Cloudflare Workers. Anything the client writes clears the kept answers, polled reads (`realtime`) always go out, and `api.clearCache()` forgets everything, as the toolbar's **Refresh** does.

### Requests and polling

The dashboard asks only for what the open view shows. **All sites** loads every site's numbers in one request (`GET /summary`), however many sites there are. While the **Live** switch is on, the range moves forward once a minute and the view reloads; the live map and feed refresh every 10 seconds, the live block in the overview every 30 seconds. When an answer shows nothing new, the next one waits twice as long, up to a minute for the live map and two minutes for the live block, and any change goes back to the short wait. Nothing is polled while the browser tab is hidden, and coming back to it refreshes at once. Polling rather than a WebSocket keeps the server stateless, so it runs the same on serverless hosts, where a held connection would need a separate always-on piece (on Cloudflare, a Durable Object) to receive the visits and push them out. A live refresh reads the whole range again, so on a database that bills rows read (D1, for one), leave **Live** off on dashboards that stay open for hours.

## Customizing the overview

The **Customize** button in the overview toolbar turns the overview into a layout editor. Each block gets buttons to move it earlier or later, to switch between full and half width, and to remove it. A **+** on the left or right edge of a block adds a new block before or after it, and the **Add a block** tile adds one at the end. Both open a catalogue of blocks grouped by category: traffic (summary and chart, acquisition, key numbers, campaigns), content (pages, goals, funnel), audience (geography, devices, device split), revenue (channel performance, revenue by source or country, average order value, recent payments) and live. **Done** closes the editor.

The summary block has its own settings in the editor: each number can be swapped for another (visitors, sessions, pageviews, revenue, orders, conversion rate, revenue per visitor, bounce rate, visit duration or live visitors), removed, or added, up to eight, and the **Chart** row picks which metrics the chart combines, one to four. Outside the editor, a click on a number plots it on its own: counts as an area, money and durations as bars, rates as a line. Clicking more numbers adds them to the same chart, up to four, each in its own colour (the tile's marker shows which). Metrics of the same kind share a scale: the first kind is measured on the left axis, the second on the right, and any further one scales on its own, with exact values in the tooltip. The legend hides and shows each metric. Clicking a plotted number takes it out; the link above the chart goes back to the configured chart.

**Reset** next to **Done** asks for confirmation, then brings back the six default blocks and the default summary. In your own layout, `<Overview customizing />` shows the editor without the toolbar button.

### Where settings are kept

The overview layout, the summary choices and the **Live** switch are saved in the database (`GET` and `PUT /preferences`) and loaded when the dashboard opens, so they are the same in every browser and for every admin. A copy in the browser's local storage shows them at once while the database copy loads. A browser that has settings while the database has none uploads its own. `<Dashboard syncPreferences={false}>` keeps them in the browser only, which suits a shared read-only demo. Parts inside a `<DashboardProvider>` save their settings the same way.

### Sticky header and toolbar

The dashboard's header and toolbar stay at the top while the page scrolls; on phones only the toolbar does, as one row that scrolls sideways. If your page has its own sticky bar above the dashboard, set `--analytik-sticky-top` to its height, and keep `overflow: hidden` off the dashboard's containers (use `overflow: clip`), or sticking stops at that container.

## Test data for local testing

Settings has a **Debug** section for trying the dashboard with heavy data: many sites, years of history, revenue in the millions. It fills the database with made-up visits, goals and payments, either into the open site or into up to 20 new test sites. Zero days or zero visits make empty sites, to see the dashboard before any data arrives. Presets cover a quiet month, a busy quarter, a dozen sites, a realistic mix of very different sites (empty, quiet, just launched, paused, a busy shop), big money and a stress test of about a million visits. Every amount can be changed, and the form estimates the visits and events before writing. **Delete test sites** removes the generated sites again; data generated into one of your own sites stays until you delete that site or its old data.

The section only works against a server created with `debug: true`, and explains how to turn it on otherwise. Use it on a local or staging database, never on one with real data:

```ts
const analytik = createAnalytik({ storage, admin, debug: true })
```

The example app's dev server (`bun run dev` in `apps/example`) has it on.

## Install it as an app

The dashboard runs on your own page, so making it installable is up to that page: a web app manifest and a service worker turn it into an app on phones and desktops, with its own icon and window. Sign-in keeps working, because the installed app uses the same cookies as the site.

Link a manifest from the page that mounts `<Dashboard>`:

```html
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#f2f4f8" />
```

```json
{
  "name": "Analytics",
  "start_url": "/analytics",
  "scope": "/analytics",
  "display": "standalone",
  "background_color": "#f2f4f8",
  "theme_color": "#f2f4f8",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}
```

Then register a service worker. A small one is enough: load pages from the network and fall back to the last copy, keep the hashed build files, and leave the API alone.

```js
// sw.js
const CACHE = 'analytics-v1'

self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))

self.addEventListener('fetch', (event) => {
  const { request } = event
  const url = new URL(request.url)
  // Never cache the analytik API: its answers are private and must be current.
  if (request.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/')) return
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request)
        .then((response) => {
          const copy = response.clone()
          caches.open(CACHE).then((cache) => cache.put(request, copy))
          return response
        })
        .catch(() => caches.match(request).then((cached) => cached ?? Response.error())),
    )
  }
})
```

```ts
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js')
```

Serve `sw.js` with `Cache-Control: no-cache`, so a new version reaches installed apps. Without a network the app opens, and its cards show the request error until the device is back online; the refresh button in the toolbar then loads the numbers again. On iPhone and iPad, "Add to Home Screen" in Safari installs it. The [demo](/examples) is installable the same way.

## Styling

`analytik/dashboard/styles.css` defines CSS variables with the `--analytik-` prefix, mapped to the design tokens: neutrals with one blue accent, 1px borders and no shadows. Override them on any ancestor:

```css
.admin {
  --analytik-accent: #0f766e;
}

.admin[data-theme='dark'] {
  --analytik-accent: #2dd4bf;
}
```

## Share links

**Settings → Sharing** makes read-only links to a site. A link opens the dashboard without signing in: the views you chose (or all of them), no Settings, no editing, no other site. Give a link a name so you know who has it, choose its views and an expiry, and revoke it at any time; it stops working at once. The link is shown once when you create it, so copy it then.

A link is the address of your dashboard page with `?site=<id>&share=<token>`, so the page that opens it must show `<Dashboard>`. `<Dashboard share="TOKEN" siteId="...">` does the same without reading the address.

A shared dashboard can read the aggregate stats and the session list: visitor ids, countries, cities, browsers, pages, referrers and event names, in the views you allow. Payments, single-session details, settings, export and every write stay admin only. See the [HTTP API](/reference/http-api#share-links).

### Security notes

- A token is 32 random bytes. Only its SHA-256 is stored, so a copy of the database cannot open a link.
- Anyone who has a link sees everything its views show. Treat it like a password, give it an expiry when it is for a short job, and revoke it when it is done.
- Responses read through a link in the address carry `Cache-Control: public, s-maxage=60` (10 seconds for the live view), so a CDN such as Cloudflare answers repeat views without reaching your database. The token is part of the cache address, so one link's data never answers another request. A token sent in the `x-analytik-share` header is never cached.
- A shared page keeps the choices a visitor makes (layout, language, theme) in their own browser; nothing is written to the database.
- The older single `shareToken` on a site (`PATCH /sites/:id` with `{ "shareToken": true }`) still works and opens every shareable view. It has no name, expiry, view limit or caching, so prefer share links.

## Notes on the chart

An admin can add a dated note to the summary and revenue charts, for example "Launched on Product Hunt" or "Deployed v2". Notes show as flags on the chart; hover one for its text, click it to edit or delete. Flags that fall close together merge into one with a count. A shared dashboard shows the notes but cannot change them.

Notes arrive inside the chart's own response, so they cost no extra request: each `TimeseriesPoint` carries the notes dated in its bucket as `notes`, at most 100 per range. A deploy script can add one with the site's API key:

```bash
curl -X POST https://analytics.example.com/api/v1/annotations \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{"text":"Deployed v2","ts":"2026-03-03"}'
```

`ts` is a Unix time in milliseconds or an ISO 8601 date, and defaults to now. The text is plain text, at most 200 characters. A site keeps up to 2000 notes. Run `analytik upgrade` after updating to create the `annotations` and `share_links` tables.

## Accessibility

The dashboard is checked with axe on every view, the command palette and the main dialogs, in the light and the dark theme.

- **Keyboard:** everything works without a mouse. Tab reaches a chart, the arrow keys step through its points and show the values, and Tab leaves it. Map markers and chart notes open with Enter or Space; `?` lists the shortcuts.
- **Reduced motion:** with `prefers-reduced-motion` the animations are skipped.
- **Contrast:** text and controls meet WCAG AA in both themes.
- **Screen readers:** each chart has a name and a data table, the map has a list of its places, and the view heading is exposed. After the view, range, comparison or filters change, one polite message such as "Updated Overview: Past 7 days, 1 filter" says the numbers have changed; it stays silent on a refresh and on first load.
- **Touch:** on a coarse pointer controls have a hit area of at least 24 by 24 pixels, and the main ones 44 by 44, without changing the layout.

Known gaps: there is no forced-colors (Windows high contrast) styling, and the Journeys bars are smaller than 44 pixels on touch screens.

## Export

Every table has an **Export CSV** button, and each breakdown a **Export CSV** link: they save what is on screen (the visible columns and rows of the current page) with no new request. Each chart has **Export PNG**, drawn in the browser. Cells that begin with `=`, `+`, `-` or `@` are prefixed with an apostrophe so a spreadsheet does not run them as formulas. The whole-site backup in Settings → Data is separate.

## Keeping the place in the address

Pass `urlState` and the dashboard keeps the open place in the address, for example `?site=abc123&section=pages&range=30d&f.country=SK`. A refresh, a bookmark or a shared link opens the same place. The browser's Back button steps through sites and views; changing a Settings section, the range, the interval or a filter replaces the current entry, so Back does not walk through every click. The standalone dashboard served by `withDashboard` does this by default. An embedded dashboard leaves the host page's address alone unless you ask, and keeps the host's own parameters when you do.

| Parameter       | Holds                                                    | Left out when                     |
| --------------- | -------------------------------------------------------- | --------------------------------- |
| `site`          | The open site's id                                       | Never                             |
| `section`       | The view, such as `pages` or `settings`                  | It is `overview`                  |
| `tab`           | The Settings section, such as `privacy`                  | The view is not Settings          |
| `range`         | A preset: `today`, `24h`, `7d`, `30d`, `90d` or `12m`    | It is `7d`                        |
| `from`, `to`    | A custom range as `YYYY-MM-DD` dates, both days included | The range is a preset             |
| `interval`      | `hour`, `day`, `week` or `month`                         | It is the usual one for the range |
| `compare`       | `previous` or `year`                                     | There is no comparison            |
| `segment`       | The id of a saved segment, which stands for its filters  | No segment                        |
| `f.<dimension>` | A filter, such as `f.country=SK`                         | The filter is off                 |

Unknown values are ignored. Reading and writing the address costs no request: each block loads once for the restored state.
