The platform uses a self-hosted Plausible instance at
https://plausible.earthmonitor.org for both automatic and custom analytics.
- Setup
- Automatic tracking
- Custom events
- Event sources
- Adding a new event
- Conventions and gotchas
- Relationship to
/usage-stats
Plausible is wired up in src/utils/providers.tsx via
next-plausible:
<PlausibleProvider
domain="app.earthmonitor.org"
trackOutboundLinks
trackFileDownloads
selfHosted
scriptProps={{
src: 'https://plausible.earthmonitor.org/js/script.file-downloads.hash.outbound-links.pageview-props.tagged-events.js',
}}
>Two things about this block are easy to get wrong:
domainmust be a bare hostname.next-plausiblepasses the prop straight through to the script'sdata-domainattribute, and Plausible matches that value against the domain registered in the dashboard, which is stored without a protocol or trailing slash. A value likehttps://app.earthmonitor.org/silently drops every event — the script loads, requests fire, and nothing is ever attributed.scriptProps.srcoverrides the computed URL.next-plausiblenormally derives the script filename from the boolean props, but anything inscriptPropsis spread last and wins. The hardcoded URL above must therefore already include every extension the app relies on:file-downloads,hash,outbound-links,pageview-props,tagged-events. Adding a prop likerevenuewithout also updating this URL would have no effect.
PlausibleProvider is enabled by default only when
NODE_ENV === 'production' and NEXT_PUBLIC_VERCEL_ENV is either unset or
'production'. This project deploys via Docker, so the Vercel variable is
unset and a production build always tracks. Nothing is sent from yarn dev.
To verify events locally, add trackLocalhost to the provider temporarily —
don't commit it, or local development will pollute production stats.
Handled by the Plausible script itself, no application code involved:
| What | Source |
|---|---|
| Pageviews | Core script |
| Outbound link clicks | trackOutboundLinks |
| File downloads | trackFileDownloads |
All custom events go through one typed helper, src/lib/analytics.ts:
import { useTrackEvent } from '@/lib/analytics';
const track = useTrackEvent();
track('Geostory Open', {
props: { geostory_id: id, title, source: 'geostories-page' },
});useTrackEvent is usePlausible<AnalyticsEvents>(). Because the event map is
passed as the type parameter, both the event name and its props are checked at
the call site — a typo in a name, a missing prop, or a wrong source value is
a build error rather than a silently malformed event.
Every event and its properties are declared in the AnalyticsEvents type. That
type is the single source of truth; the table below mirrors it.
A user navigated into a geostory.
| Prop | Type |
|---|---|
geostory_id |
string |
title |
string |
source |
EventSource |
Fired from seven places — every entry point into a geostory:
| File | Surface |
|---|---|
containers/globe/geostories/geostories-list/item.tsx |
Landing globe geostory list |
components/globe/geostory-dialog.tsx |
Landing globe dialog, arrow button |
components/geostories/item/index.tsx |
Geostories page card |
components/monitors/table/item/geostory.tsx |
A monitor's expandable geostory sublist |
components/sidebar/card-header.tsx |
Explore sidebar, card title link |
components/sidebar/card-geostory-content.tsx |
Explore sidebar, "Go to geostory" |
components/sidebar/card-monitor-content.tsx |
Explore sidebar, "Related geostories" |
The last three live in the same sidebar card but are separate <Link>s — a
click on the chevron never runs the card header's handler, so each needs its
own call.
A user navigated into a monitor.
| Prop | Type |
|---|---|
monitor_id |
string |
title |
string |
source |
EventSource |
| File | Surface |
|---|---|
components/monitors/table/item/monitor.tsx |
Monitors page link |
components/sidebar/card-header.tsx |
Explore sidebar, card title link |
components/sidebar/card-monitor-content.tsx |
Explore sidebar, "Go to monitor" |
A dataset layer was switched on. Deactivation is deliberately not tracked — turning a layer off is not an interest signal and only adds noise to the breakdown.
| Prop | Type |
|---|---|
layer_id |
string |
title |
string |
parent_type |
'monitor' | 'geostory' |
Fired from components/datasets/card/index.tsx, inside the activation branch of
handleToggleLayer.
A category (theme) was selected. Clearing a category is not tracked, so the breakdown reads as interest per category rather than raw toggle volume.
| Prop | Type |
|---|---|
category |
CategoryId | 'All' |
source |
EventSource |
| File | Surface |
|---|---|
containers/globe/filters/item.tsx |
Landing globe category pills (multi-select) |
components/theme-filter/map-sidebar-item.tsx |
Explore sidebar icon rail (single-select, plus "All") |
containers/globe/mobile-toolbar.tsx |
Landing mobile toolbar |
containers/explore/toolbar/mobile/nav-bar/index.tsx |
Explore mobile nav bar |
The landing globe pills are a genuine toggle, so the call there is guarded by
if (!isActive). The other three surfaces only ever set a category, so they
fire unconditionally. The mobile "Catalogue" back button calls
setCategory(null) and is not tracked — that is a clear, not a selection.
The monitors / geostories / all switch changed.
| Prop | Type |
|---|---|
dataset_type |
'all' | 'monitors' | 'geostories' |
source |
EventSource |
| File | Surface |
|---|---|
components/sidebar/select.tsx |
Explore sidebar select |
containers/globe/mobile-toolbar.tsx |
Landing mobile toolbar |
containers/explore/toolbar/mobile/nav-bar/index.tsx |
Explore mobile nav bar |
components/filters-by-dataset-type/desktop is presentational and receives
handleDatasetTypeChange as a prop, so the event lives in each of the three
containers that own the setter — not in the shared component.
A histogram was opened from a map tooltip.
| Prop | Type |
|---|---|
histogram_type |
'point' | 'region' |
layer_id |
string |
source |
EventSource |
The two histogram buttons are mutually exclusive and driven by
regionsLayerVisibilityAtom: "Show point histogram" renders while the
regions layer is off, "Show region histogram" while it is on. Rather than
two event names, one event carries histogram_type so the two can be compared
in a single breakdown.
| File | Handler | histogram_type |
|---|---|---|
components/map/tooltip/index.tsx |
handleClick |
point |
components/map/tooltip/index.tsx |
handleHistogram |
region |
components/map/tooltip/geostory-tooltip.tsx |
handleClick |
point |
components/map/tooltip/geostory-tooltip.tsx |
handleHistogram |
region |
In the geostory tooltip both handlers are bound twice — once for leftData and
once for rightData in compare mode — but they always describe the same layer,
leftData.id.
The regions (NUTS) overlay was switched on. Switching it off is not tracked,
matching Layer Activate.
| Prop | Type |
|---|---|
source |
EventSource |
Fired from components/map/controls/basemaps/index.tsx.
| Prop | Type |
|---|---|
basemap |
'world_imagery' | 'gray_scale' |
source |
EventSource |
| Prop | Type |
|---|---|
labels |
'dark' | 'light' | 'no-label' |
source |
EventSource |
Both are fired from components/map/controls/basemaps/index.tsx, and both
handlers already early-return when the value is unchanged, so re-selecting the
active option sends nothing.
The prop types are imported from
components/map/controls/basemaps/constants as import type, so adding a
basemap or label there widens the event type automatically with no runtime
coupling.
A publication slide in the landing page live feed was clicked.
publication_source separates the two libraries, so the breakdown shows which
one people actually follow through on.
| Prop | Type |
|---|---|
publication_source |
'zenodo' | 'zotero' |
title |
string |
source |
EventSource |
| File | Surface |
|---|---|
containers/globe/social-media/desktop/index.tsx |
Landing globe live feed, desktop |
containers/globe/social-media/mobile/index.tsx |
Landing globe live feed, mobile drawer |
Plausible also records these as outbound link clicks, since every card is an
external <a>; the custom event adds the source and title breakdown.
source is a closed union rather than a free string, so a surface is always
reported under the same name and breakdowns stay comparable over time.
| Value | Where |
|---|---|
landing-globe |
Landing page globe, desktop |
landing-globe-dialog |
Geostory dialog on the landing globe |
landing-globe-mobile |
Landing page mobile toolbar |
geostories-page |
Geostories listing page |
monitors-page |
Monitors listing page |
monitor-geostories |
Geostory sublist inside a monitor row |
explore-sidebar |
Explore page sidebar, desktop |
explore-mobile |
Explore page mobile nav bar |
map-controls |
Map settings popover (basemap, labels, regions) |
map-tooltip |
Monitor map tooltip |
geostory-map-tooltip |
Geostory map tooltip |
- Add the event name and its props to
AnalyticsEventsinsrc/lib/analytics.ts. Add a newEventSourcemember if the surface isn't listed yet. - Call it from a client component:
const track = useTrackEvent(); track('My Event', { props: { ... } });
- If the call sits inside a
useCallback, addtrackto the dependency array. It is referentially stable (usePlausiblememoises with[]), so this doesn't cause re-renders. - Allow-list the prop keys in the Plausible dashboard. See below.
- Update this document.
Custom properties are not automatically visible. Each key must be added under Site settings → Custom properties in the Plausible dashboard, otherwise the event count is recorded but every breakdown shows up empty. The keys currently in use:
geostory_id, monitor_id, layer_id, title, source,
parent_type, category, dataset_type, histogram_type, basemap, labels
- Activation only. For anything that toggles — layers, categories, the regions overlay — only the "on" transition is tracked. Off-transitions roughly double event volume while adding no signal.
- Never track inside a state updater. React may invoke an updater function
more than once, which would double-count. Compute the condition from current
state and fire before calling the setter:
if (!isActive) track('Category Filter', { props: { ... } }); setCategoriesFilter((prev) => { ... });
- Hooks before early returns. Several instrumented components early-return
(
if (!geostory) return null).useTrackEvent()must be called above that line to keep hook order stable across renders. - Events are queued, not lost.
PlausibleProviderinjects an init snippet that stubswindow.plausiblewith a queue, so a click that lands before the script finishes loading is still delivered. - Instrument the container, not the shared presentational component. Where a
handler is passed down as a prop, the event belongs with whoever owns the
state, so the
sourcecan be set correctly per surface.
Plausible is not the only counter in the app. postWebTraffic in
src/hooks/web-traffic.ts POSTs to the backend /usage-stats endpoint, and
those counts drive the "most visited" geostories and monitors shown in the UI.
The two are independent and intentionally so — /usage-stats is a product
feature whose numbers are rendered to users, while Plausible is for analysis.
Some handlers call both. Don't replace one with the other.