Embedding the dashboard
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. 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
bun add analytik react react-dom @base-ui/react motion recharts
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.
<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}}.
<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 ⌘ K (Ctrl K 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 |
|---|---|
| ⌘ K or Ctrl K | Open the command palette |
| g then o | Go to Overview |
| g then c | Go to Acquisition |
| g then p | Go to Pages |
| g then e | Go to Events |
| g then f | Go to Funnels |
| g then r | Go to Revenue |
| g then s | Go to Sessions |
| g then l | Go to Live |
| g then t | Go to Settings |
| g then a | Go to All sites |
| [ and ] | Previous and next period |
| r | Refresh |
| ? | 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 Ctrl, Alt or ⌘ 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:
const analytik = createAnalytik({
storage,
admin: { verify: async (request) => (await getSession(request))?.role === 'admin' },
})
<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:
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, plusSitesOverview(a card per site; takesclient,sites,onOpenandonAdd, and optionallyonImportto turn “Add website” into a menu with an import entry). - Dialogs and forms:
AddSiteDialog(takesclient,onCreatedandonClose),CreateSiteForm,LoginForm. - Pieces of a view:
BreakdownCard,SessionTimeline,SiteSettings,PrivacySettings,SnippetGenerator,CampaignLinkBuilder. - Charts and widgets:
AreaChart,BarList,Sparkline,StatTile,DeltaBadge, andWorldMap(takeslocationsfrom aRealtimeresult, an optionalglobe, andselectedwithonSelectto make the markers buttons). - Toolbar controls:
RangePicker,IntervalPicker,FilterBar. - Helpers:
createApiClient,RequestError,queryParams,useDashboard, and the range helperspresetRange,customRange,shiftRangeandRANGE_PRESETS.
If you want your own components, createApiClient(api, fetcher?) returns a typed client for the admin routes, with the response types from Types. api is the API base path as a string, and the optional second argument is a fetch-compatible function:
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:
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>:
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#f2f4f8" />
{
"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.
// 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())),
)
}
})
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 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:
.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.
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 thex-analytik-shareheader 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
shareTokenon a site (PATCH /sites/:idwith{ "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:
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-motionthe 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§ion=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.