analytics
Sooner or later someone asks how many people created a project this week. @kitr/analytics gives you four calls, page, track, identify and reset, and sends them to Google Analytics, Plausible, PostHog or several at once.
Page views are counted for you, and calls can wait until the visitor agrees to tracking.
The examples follow Acme's app at app.acme.com: how often Ada creates a project, which plan Grace's team is on, and when Linus pays an invoice.
It runs in the browser. For events your server owns, such as a paid invoice, write them to your database or send them from a job. To report errors rather than usage, use monitoring.
Install
kitr add analyticsFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Everything kitr add changes
What kitr add does:
- Installs
@kitr/analytics. - Adds
() => import('@kitr/analytics/provider')toprovidersand'@kitr/analytics'topackagesinkitrrc.ts. - Adds
VITE_PLAUSIBLE_DOMAIN,VITE_PLAUSIBLE_HOSTandVITE_GA_MEASUREMENT_ID, eache.string({ optional: true }), to thepublicblock ofapp/env.ts, and an empty line for each to.env.example(and to.envwhen that file exists). - Writes
config/analytics.ts(shown in Configure) and regeneratesconfig/index.ts. With consent already installed, theenabled: falsehint becomes a comment saying analytics is held until the visitor accepts. - Writes
app/analytics.ts, the typed list of your events. It starts empty, with two events as a commented example. - Adds a
## kitr: analyticssection toAGENTS.mdtelling coding agents to track throughuseAnalytics<AppEvents>(), to declare new events inapp/analytics.tsfirst, and never to call gtag or posthog directly.
kitr add analytics asks no questions. Pass --no-install to skip the package manager. The saas and site kits include analytics. In a server-only project it prints a warning and skips.
kitr remove analytics removes the provider, the packages entry, the dependency and the AGENTS.md section, and deletes config/analytics.ts. It leaves app/analytics.ts, the env variable and every track() call, which you delete by hand.
Configure
kitr add analytics writes this file:
// config/analytics.ts
import { defineConfig, googleAnalytics, plausible } from '@kitr/analytics'
import { env } from '#app/env'
/**
* With no adapters, development logs every event to the console and production sends nothing,
* so the app works before any vendor is chosen. Plausible is cookieless; set its domain first.
*/
export default defineConfig({
adapters: [
...(env.VITE_PLAUSIBLE_DOMAIN
? [plausible({ domain: env.VITE_PLAUSIBLE_DOMAIN, apiHost: env.VITE_PLAUSIBLE_HOST })]
: []),
...(env.VITE_GA_MEASUREMENT_ID
? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID })]
: []),
// posthog(posthogJs, { apiKey: env.VITE_POSTHOG_KEY }), // pnpm add posthog-js
],
// Consent banners: start queued, then call analytics.enable() once the user agrees.
// enabled: false,
globalProps: { app: env.VITE_APP_NAME },
})
With both variables empty, development logs every call to the console and production sends nothing, so your buttons work before you choose a vendor.
| Option | Default | Description |
|---|---|---|
adapters |
[] |
Where calls go. Each entry is an adapter such as googleAnalytics() or plausible(). When empty, the provider uses consoleAdapter() in development and nothing in production. |
enabled |
true |
Send at once. false queues up to 100 calls until analytics.enable(). With consent, it waits for consent. |
pageViews |
true |
Send page() on every route change. false leaves page views to you. |
globalProps |
none | Props merged into every page() and track() call, such as the app name or build id. Props on the call win. |
debug |
true in development |
Print every call and adapter error to the console. Off when devtools is installed. |
events |
none | A type-only field that carries your event map. You never set it at run time. |
| Variable | Used for |
|---|---|
VITE_PLAUSIBLE_DOMAIN |
The site as Plausible knows it, such as app.acme.com. When set, the generated config adds the Plausible adapter. |
VITE_PLAUSIBLE_HOST |
Optional. Your self-hosted Plausible, such as https://plausible.acme.com. Default https://plausible.io. |
VITE_GA_MEASUREMENT_ID |
Your Google Analytics 4 measurement id, such as G-ACME12345. When set, the generated config adds the Google Analytics adapter. |
VITE_APP_NAME |
Already in your env. The generated config sends it as the app prop on every event. |
Track your first event
kitr add wrote app/analytics.ts, an empty list of the events the app sends. Declare project.created there, with the props it carries:
// app/analytics.ts
import type { EventMap } from '@kitr/analytics'
/**
* Every event the app sends, with its props. `useAnalytics<AppEvents>()` checks names and props,
* so declare an event here before you track it.
*/
export interface AppEvents extends EventMap {
'project.created': { id: string }
}
In a component, get the analytics service with useAnalytics() from @kitr/analytics/react and call track() after the thing happened:
// app/components/projects/new-project-button.tsx
import { useAnalytics } from '@kitr/analytics/react'
import type { AppEvents } from '#app/analytics'
export function NewProjectButton({ onCreate }: { onCreate: () => Promise<{ id: string }> }) {
const analytics = useAnalytics<AppEvents>()
return (
<button
type="button"
onClick={async () => {
const project = await onCreate()
analytics.track('project.created', { id: project.id })
}}
>
New project
</button>
)
}
Click it in development, with no adapter configured, and the console shows:
[analytics] track project.created { app: 'acme', id: 'prj_42' }
The app prop comes from globalProps. With devtools installed, the call also appears in the Analytics panel.
Set VITE_PLAUSIBLE_DOMAIN=app.acme.com in .env, restart, and the same click sends project.created to Plausible. VITE_GA_MEASUREMENT_ID=G-ACME12345 does the same for Google Analytics. No component changes.
Declare your events
Add a line to AppEvents for every new event. Use undefined for an event without props:
// app/analytics.ts
import type { EventMap } from '@kitr/analytics'
export interface AppEvents extends EventMap {
'sign-in.completed': { method: 'password' | 'oauth' }
'project.created': { id: string }
'invoice.paid': { invoiceId: string; amount: number; currency: 'USD' | 'EUR' }
'team.invited': { teamId: string; role: 'admin' | 'member' }
'upgrade.clicked': undefined
}
useAnalytics<AppEvents>() gives your editor the names and props, so analytics.track('inv completes to 'invoice.paid':
// app/components/invoices/pay-button.tsx
import { useAnalytics } from '@kitr/analytics/react'
import type { AppEvents } from '#app/analytics'
export function PayButton({ invoiceId, amount }: { invoiceId: string; amount: number }) {
const analytics = useAnalytics<AppEvents>()
return (
<button
type="button"
onClick={() => analytics.track('invoice.paid', { invoiceId, amount, currency: 'USD' })}
>
Pay {amount} USD
</button>
)
}
With an event map, a misspelt name, a missing prop or a wrong type is a type error. Without one (useAnalytics() with no type argument), track() takes anything.
Name events object.action in the past tense, like project.created, so dashboards stay readable.
Building your own instance
defineEvents<AppEvents>() ties the event map to a config object for code that builds its own analytics instance:
// app/lib/onboarding/analytics.ts
import { consoleAdapter, createAnalytics, defineEvents } from '@kitr/analytics'
import type { AppEvents } from '#app/analytics'
const config = defineEvents<AppEvents>()({ adapters: [consoleAdapter({ prefix: '[onboarding]' })] })
export const onboardingAnalytics = createAnalytics<AppEvents>(config)
Track page views
The provider calls page() once for the landing page and again after every navigation that changes the path. A change to the search or hash alone is not a new page view. Each call carries the path, url and title:
// What the tracker sends when Ada opens her projects page
analytics.page('/projects', { url: 'https://app.acme.com/projects', title: 'Projects · Acme' })
// adapters receive: ('/projects', { app: 'acme', url: 'https://app.acme.com/projects', title: 'Projects · Acme' })
The tracker needs the router that createKitrRouter() from @kitr/router binds; without one, it sends nothing. To send page views yourself, turn the automatic ones off:
// config/analytics.ts
import { defineConfig } from '@kitr/analytics'
export default defineConfig({
adapters: [],
pageViews: false,
})
Then call page() where you decide a page was seen, such as a wizard step with no URL change:
// app/components/onboarding/step.tsx
import { useAnalytics } from '@kitr/analytics/react'
import { useEffect } from 'react'
export function OnboardingStep({ step }: { step: 'team' | 'project' | 'invite' }) {
const analytics = useAnalytics()
useEffect(() => {
analytics.page(`/onboarding/${step}`, { title: `Onboarding: ${step}` })
}, [analytics, step])
return null
}
Identify a signed-in user
After sign-in, tell the services who the visitor is, so later events join up. Pass a stable id, never an email address:
// app/components/auth/sign-in-form.tsx
import { useAnalytics } from '@kitr/analytics/react'
import type { AppEvents } from '#app/analytics'
export function useAfterSignIn() {
const analytics = useAnalytics<AppEvents>()
return (user: { id: string; plan: 'free' | 'team' }) => {
analytics.identify(user.id, { plan: user.plan })
analytics.track('sign-in.completed', { method: 'password' })
}
}
Google Analytics sets user_id and the plan user property, PostHog calls posthog.identify, and Plausible, which has no user profiles, ignores it. identify() does not add globalProps.
Forget the user on sign-out
Call reset() on sign-out so the next person on a shared computer is not counted as them:
// app/components/auth/sign-out-button.tsx
import { useAnalytics } from '@kitr/analytics/react'
export function SignOutButton({ onSignOut }: { onSignOut: () => Promise<void> }) {
const analytics = useAnalytics()
return (
<button
type="button"
onClick={async () => {
await onSignOut()
analytics.reset()
}}
>
Sign out
</button>
)
}
Google Analytics clears user_id and PostHog calls posthog.reset().
Add props to every event
globalProps are merged into every page() and track() call. Use them for facts about the build, not the visitor:
// config/analytics.ts
import { defineConfig, plausible } from '@kitr/analytics'
import { env } from '#app/env'
export default defineConfig({
adapters: [plausible({ domain: 'app.acme.com' })],
globalProps: { app: env.VITE_APP_NAME, release: '2026.11.1' },
})
A prop on the call wins over a global prop with the same name: track('project.created', { id: 'prj_42', release: 'canary' }) sends release: 'canary'.
Track outside a component
Outside React, app.container.use('analytics') returns the instance the components use:
// app/lib/projects/create-project.ts
import type { Analytics } from '@kitr/analytics'
import type { KitrApp } from '@kitr/kitr/app'
import type { AppEvents } from '#app/analytics'
export async function createProject(app: KitrApp, name: string) {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
})
const project: { id: string } = await response.json()
const analytics = app.container.use('analytics') as Analytics<AppEvents>
analytics.track('project.created', { id: project.id })
return project
}
Adapters
An adapter sends calls to one service. Every call goes to each listed adapter in order. Each loads its own script the first time tracking is enabled in the browser, never on the server.
| Adapter | Sends to | Script or client |
|---|---|---|
googleAnalytics({ measurementId }) |
Google Analytics 4 | Loads gtag.js for you. |
plausible({ domain, apiHost }) |
Plausible, cloud or self-hosted | Loads script.manual.js for you. |
posthog(client, { apiKey }) |
PostHog | You install posthog-js and pass it in. |
consoleAdapter({ prefix }) |
The browser console | None. The development default. |
memoryAdapter() |
An array in memory | None. For tests. |
Google Analytics
Nothing to install. googleAnalytics() injects gtag.js once and turns off gtag's own page views, so the router decides.
// config/analytics.ts
import { defineConfig, googleAnalytics } from '@kitr/analytics'
import { env } from '#app/env'
export default defineConfig({
adapters: env.VITE_GA_MEASUREMENT_ID
? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID, debug: false })]
: [],
})
Then set the id in .env:
VITE_GA_MEASUREMENT_ID=G-ACME12345
| Option | Default | Description |
|---|---|---|
measurementId |
required | The G- id of your GA4 data stream. |
debug |
off | Sets gtag's debug_mode, so events show in GA's DebugView. |
page() becomes a page_view event; track('project.created', { id }) becomes gtag('event', 'project.created', { id }). GA4 has its own rules for event names; see the GA4 event reference.
Plausible
Nothing to install. plausible() injects Plausible's manual script once.
// config/analytics.ts
import { defineConfig, plausible } from '@kitr/analytics'
export default defineConfig({
adapters: [plausible({ domain: 'app.acme.com' })],
})
For a self-hosted Plausible, point apiHost at it:
// config/analytics.ts
import { defineConfig, plausible } from '@kitr/analytics'
export default defineConfig({
adapters: [plausible({ domain: 'app.acme.com', apiHost: 'https://stats.acme.com' })],
})
| Option | Default | Description |
|---|---|---|
domain |
required | The site as registered in Plausible. |
apiHost |
https://plausible.io |
Where the script is loaded from. |
page() sends a pageview; track() sends a custom event with props. identify() and reset() do nothing. Create a goal in Plausible for each event name you want to count; see custom event goals.
PostHog
Install the PostHog browser client, then pass it in:
pnpm add posthog-js
// config/analytics.ts
import posthogJs from 'posthog-js'
import { defineConfig, posthog } from '@kitr/analytics'
import { env } from '#app/env'
export default defineConfig({
adapters: env.VITE_POSTHOG_KEY
? [
posthog(posthogJs, {
apiKey: env.VITE_POSTHOG_KEY,
apiHost: 'https://eu.i.posthog.com',
config: { autocapture: false },
}),
]
: [],
})
Declare the key in app/env.ts:
// app/env.ts
import { createEnv, env as e } from '@kitr/kitr/env'
export const env = createEnv({
public: {
// ...
VITE_GA_MEASUREMENT_ID: e.string({ optional: true }),
VITE_POSTHOG_KEY: e.string({ optional: true }),
},
server: {},
})
Then set it in .env:
VITE_POSTHOG_KEY=phc_acme
| Option | Default | Description |
|---|---|---|
apiKey |
required | Your PostHog project API key. |
apiHost |
https://us.i.posthog.com |
Your PostHog region or proxy. |
config |
none | Any other posthog.init() options, merged last. |
Automatic page views are off so the router drives them; page() becomes a $pageview capture. Any object with init, capture, identify and reset works as the client. See the posthog-js docs for config options.
Console
consoleAdapter() prints each call with console.info; it is the development default. Add it next to a real service, with a prefix:
// config/analytics.ts
import { consoleAdapter, defineConfig, plausible } from '@kitr/analytics'
export default defineConfig({
adapters: [plausible({ domain: 'app.acme.com' }), consoleAdapter({ prefix: '[acme]' })],
})
[acme] page /projects { app: 'acme', url: 'https://app.acme.com/projects', title: 'Projects · Acme' }
[acme] track project.created { app: 'acme', id: 'prj_42' }
Memory
memoryAdapter() records every call in its calls array as { type, args }. Use it in tests; see Testing.
Wait for consent
Set enabled: false and every call waits in a queue instead of going out:
// config/analytics.ts
import { defineConfig, googleAnalytics } from '@kitr/analytics'
import { env } from '#app/env'
export default defineConfig({
adapters: env.VITE_GA_MEASUREMENT_ID
? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID })]
: [],
enabled: false,
})
When the visitor agrees, call enable(). It loads each adapter's script, then sends the queued calls in order:
// app/components/tracking-choice.tsx
import { useAnalytics } from '@kitr/analytics/react'
import { useState } from 'react'
export function TrackingChoice() {
const analytics = useAnalytics()
const [answered, setAnswered] = useState(analytics.enabled)
if (answered) return null
return (
<div role="dialog" aria-label="Tracking">
<p>Acme counts page views to improve the app.</p>
<button
type="button"
onClick={() => {
void analytics.enable()
setAnswered(true)
}}
>
Allow
</button>
<button
type="button"
onClick={() => {
analytics.disable()
setAnswered(true)
}}
>
No thanks
</button>
</div>
)
}
analytics.enabled is a plain property, not React state, so the component keeps its own flag. This does not remember the answer across visits; consent stores it in a cookie.
- Before
enable(), calls queue (up to 100; later calls are dropped). enable()runs every adapter'sinit(), then sends the queue. Calling it again does nothing.disable()empties the queue and stops sending; later calls queue again.
The consent package does this for you: it calls enable() when the visitor grants the analytics category and disable() when they withdraw it. With consent installed, analytics starts disabled whatever enabled says, and no script loads before the visitor agrees.
Add an adapter at run time
use() adds an adapter after startup, for a service only some visitors need, such as session replay for Acme staff:
// app/components/staff/staff-tools.tsx
import { posthog } from '@kitr/analytics'
import { useAnalytics } from '@kitr/analytics/react'
import posthogJs from 'posthog-js'
import { useEffect } from 'react'
export function StaffTools({ email }: { email: string }) {
const analytics = useAnalytics()
useEffect(() => {
if (!email.endsWith('@acme.com')) return
if (analytics.adapters.some((a) => a.name === 'posthog')) return
analytics.use(posthog(posthogJs, { apiKey: 'phc_acme_staff' }))
}, [analytics, email])
return null
}
An added adapter only receives later calls, and cannot be removed. analytics.adapters lists them by name.
Survive blocked scripts
Ad blockers and vendor outages cannot break your click handlers: adapter errors are caught, so track() never throws and the other adapters still receive the call:
import { createAnalytics, memoryAdapter } from '@kitr/analytics'
const memory = memoryAdapter()
const analytics = createAnalytics({
adapters: [
{ name: 'blocked', page: () => {}, track: () => Promise.reject(new Error('blocked')) },
memory,
],
})
await analytics.enable()
analytics.track('project.created', { id: 'prj_42' })
// => no error; memory.calls has the track call
With debug on, each failure prints as a warning; in production failures are silent.
Use it on the server
During server rendering useAnalytics() renders the same, but no adapter script loads.
Testing
Pass memoryAdapter() in config and assert on its calls, recorded as { type, args } with globalProps merged in:
// app/components/projects/new-project-button.test.tsx
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { memoryAdapter } from '@kitr/analytics'
import { analyticsProvider } from '@kitr/analytics/provider'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { describe, expect, it } from 'vitest'
import { NewProjectButton } from './new-project-button'
describe('NewProjectButton', () => {
it('tracks project.created with the new id', async () => {
const memory = memoryAdapter()
const app = await createTestApp({
providers: [analyticsProvider],
config: { analytics: { adapters: [memory], globalProps: { app: 'acme' } } },
})
renderWithKitr(<NewProjectButton onCreate={async () => ({ id: 'prj_42' })} />, { app })
fireEvent.click(screen.getByRole('button', { name: 'New project' }))
await waitFor(() =>
expect(memory.calls).toContainEqual({
type: 'track',
args: ['project.created', { app: 'acme', id: 'prj_42' }],
}),
)
})
})
For code outside components, build an instance with createAnalytics() and no app at all. This one checks the consent queue:
// app/lib/projects/analytics.test.ts
import { createAnalytics, memoryAdapter } from '@kitr/analytics'
import { expect, it } from 'vitest'
it('holds events until tracking is enabled', async () => {
const memory = memoryAdapter()
const analytics = createAnalytics({ adapters: [memory], enabled: false })
analytics.track('project.created', { id: 'prj_42' })
analytics.identify('usr_ada', { plan: 'team' })
expect(memory.calls).toEqual([])
await analytics.enable()
expect(memory.calls).toEqual([
{ type: 'track', args: ['project.created', { id: 'prj_42' }] },
{ type: 'identify', args: ['usr_ada', { plan: 'team' }] },
])
})
The assertions you will write most:
| Assertion | Checks |
|---|---|
expect(memory.calls).toContainEqual({ type: 'track', args: ['invoice.paid', expect.objectContaining({ invoiceId: 'inv_7' })] }) |
An event was sent with a prop, ignoring the others. |
expect(memory.calls.filter((c) => c.type === 'page')).toHaveLength(1) |
One page view. |
expect(memory.calls).toContainEqual({ type: 'reset', args: [] }) |
Sign-out cleared the user. |
expect(analytics.enabled).toBe(false) |
Nothing goes out before consent. |
Extend
An adapter is an object that implements AnalyticsAdapter. Only name, page and track are required:
// The interface, from @kitr/analytics
export interface AnalyticsAdapter {
name: string
init?(): void | Promise<void>
page(path: string, props?: EventProps & { url?: string; title?: string }): void
track(event: string, props?: EventProps): void
identify?(userId: string, traits?: EventProps): void
reset?(): void
}
init() runs once, in the browser, the first time tracking is enabled. Load scripts there, not at creation, so nothing loads before consent. The other methods should be quick and must not throw.
This adapter sends events to Acme's endpoint with navigator.sendBeacon, which survives the page closing:
// app/analytics/acme-adapter.ts
import type { AnalyticsAdapter, EventProps } from '@kitr/analytics'
export function acmeAdapter(options: { endpoint: string }): AnalyticsAdapter {
let userId: string | undefined
const send = (type: string, name: string, props?: EventProps) => {
const body = JSON.stringify({ type, name, props, userId, at: new Date().toISOString() })
if (!navigator.sendBeacon(options.endpoint, body)) {
void fetch(options.endpoint, { method: 'POST', body, keepalive: true })
}
}
return {
name: 'acme',
page: (path, props) => send('page', path, props),
track: (event, props) => send('track', event, props),
identify(id, traits) {
userId = id
send('identify', id, traits)
},
reset() {
userId = undefined
},
}
}
Register it in config like any built-in adapter:
// config/analytics.ts
import { defineConfig } from '@kitr/analytics'
import { acmeAdapter } from '#app/analytics/acme-adapter'
export default defineConfig({
adapters: [acmeAdapter({ endpoint: 'https://app.acme.com/api/events' })],
})
See AnalyticsAdapter in the API reference.
Devtools
With devtools installed, an Analytics panel shows whether tracking is enabled or queued, the active adapters and the last 200 calls. Enable, Disable and Clear events are also in the command palette.