Skip to main content
Solid Primitives 2

Primitives for analytics tracking with a plugin-compatible queue-based dispatch pipeline.

StageCategoryVersionLast UpdatedDemo
0Utilities2.0.0-next.2 (next)Aug 13, 2026Demo →
Terminal window
npm i @solid-primitives/analytics@next

Name: analytics Stage: 0 Package: "@solid-primitives/analytics" Primitives:

  • makeAnalytics
  • createAnalytics
  • AnalyticsProvider
  • useAnalytics
  • makeAnalyticsGuard
  • createAnalyticsGuard
  • createServerPlugin
  • createSolidStartRelayPlugin
  • createTanStackRelayPlugin Category: Utilities

Analytics tracking primitives for Solid.js with a queue-based dispatch pipeline, reactive signals, context sharing, navigation guards, and server relay support.

Plugins are not bundled — install any plugin from the analytics plugin catalogue (Google Analytics, Segment, Amplitude, Mixpanel, …) and pass it straight in.

Install analytics plugins separately:

Terminal window
npm install @analytics/google-analytics

Primitives

The package follows the standard make* / create* pattern:

PrimitiveReactive?Cleanup
makeAnalyticsNomanual cleanup()
createAnalyticsYesauto on owner disposal
AnalyticsProviderYes (via createAnalytics)auto on unmount
makeAnalyticsGuardNomanual cleanup()
createAnalyticsGuardYesauto on owner disposal

createAnalytics is built on top of makeAnalytics. AnalyticsProvider creates a createAnalytics instance and shares it via context.


makeAnalytics

The base primitive. Returns [controls, cleanup]. Use this at module level or anywhere Solid's owner tree is unavailable.

import { makeAnalytics } from "@solid-primitives/analytics";
import googleAnalytics from "@analytics/google-analytics";
const [analytics, cleanup] = makeAnalytics([
googleAnalytics({ measurementId: "G-XXXXXXXXXX" }),
]);
analytics.page({ title: "Home" });
analytics.track("signup", { plan: "pro" });
analytics.identify("user-1", { email: "alice@example.com" });
analytics.use(anotherPlugin); // register a plugin at any time
cleanup();

createAnalytics

Reactive wrapper built on makeAnalytics. Disposes automatically when the owning scope is destroyed. Exposes a reactive signal:

  • pendingCount: Accessor<number> — number of events waiting in the queue (non-zero while plugins are still initializing)
import { createAnalytics } from "@solid-primitives/analytics";
import googleAnalytics from "@analytics/google-analytics";
import { Show } from "solid-js";
function Root() {
const analytics = createAnalytics([
googleAnalytics({ measurementId: "G-XXXXXXXXXX" }),
]);
return (
<>
<Show when={analytics.pendingCount() > 0}>
<p>{analytics.pendingCount()} events queued — analytics loading</p>
</Show>
<button onClick={() => analytics.track("cta_click", { location: "hero" })}>
Get Started
</button>
</>
);
}

AnalyticsProvider / useAnalytics

The easiest way to share analytics across a component tree. Wrap your app once; call useAnalytics() in any descendant.

import { AnalyticsProvider, useAnalytics } from "@solid-primitives/analytics";
import googleAnalytics from "@analytics/google-analytics";
// At the root
<AnalyticsProvider plugins={[googleAnalytics({ measurementId: "G-XXXXXXXXXX" })]}>
<App />
</AnalyticsProvider>
// Anywhere inside the tree
function BuyButton() {
const analytics = useAnalytics();
return (
<button onClick={() => analytics.track("purchase", { plan: "pro" })}>
Buy now
</button>
);
}

AnalyticsProvider creates and owns the createAnalytics instance, disposing automatically when the provider unmounts. useAnalytics() throws ContextNotFoundError if called outside a provider.


makeAnalyticsGuard / createAnalyticsGuard pause navigation until all in-flight plugin dispatches complete, preventing events from being lost when users navigate away before async network calls finish.

Both guards:

  • Show a browser confirmation dialog on hard navigation (tab close, URL bar, page refresh) via makePageLeave from @solid-primitives/page-utilities
  • Return an onBeforeLeave handler compatible with SolidJS Router's useBeforeLeave for soft navigation

With SolidJS Router

createAnalyticsGuard returns the callback directly — pass it straight to useBeforeLeave:

import { useBeforeLeave } from "@solidjs/router";
import { useAnalytics, createAnalyticsGuard } from "@solid-primitives/analytics";
function App() {
useBeforeLeave(createAnalyticsGuard(useAnalytics()));
}

Without a router

makeAnalyticsGuard returns a tuple [onBeforeLeave, cleanup], consistent with makeAnalytics:

import { makeAnalytics, makeAnalyticsGuard } from "@solid-primitives/analytics";
const [analytics, cleanupAnalytics] = makeAnalytics([...]);
const [onBeforeLeave, cleanup] = makeAnalyticsGuard(analytics);
// wire onBeforeLeave into your routing solution
cleanup(); // call when tearing down

How it works

For soft navigation (SolidJS Router), when onBeforeLeave fires:

  1. Navigation is paused via event.preventDefault()
  2. analytics.drain() waits for all in-flight plugin dispatches to settle
  3. Navigation resumes via event.retry(true)

For hard navigation (tab close, URL bar, page refresh), the browser confirmation dialog gives the drain a chance to complete before the page unloads.


Server relay

Route analytics events through your own server function instead of calling providers directly from the browser. This keeps API keys off the client and lets you enrich events with server-side context (session data, IP-based geo, etc.) before forwarding to the provider.

browser → createServerPlugin → your server function → GA / Mixpanel / Segment

createServerPlugin

import { createServerPlugin } from "@solid-primitives/analytics/relay";

Creates a client-side plugin that forwards every event to a server function. The server function receives the full AnyPayload (including the original rid and ts) and is responsible for calling the actual provider.

// client
import { createAnalytics } from "@solid-primitives/analytics";
import { createServerPlugin } from "@solid-primitives/analytics/relay";
import { relayEvent } from "./analytics.server";
const analytics = createAnalytics([
createServerPlugin(relayEvent, { events: ["track", "identify"] }),
]);
analytics.track("purchase", { orderId: "123" }); // → server → GA

Options:

OptionTypeDescription
namestringPlugin name. Default: "server"
eventsArray<"page" | "track" | "identify">Limit which event types are relayed. Omit to relay all.

SolidStart

import { createSolidStartRelayPlugin } from "@solid-primitives/analytics/relay/solidstart";

Because SolidStart requires "use server" as a literal in your source code, define the action yourself and pass it to createSolidStartRelayPlugin:

analytics.server.ts
import { action } from "@solidjs/router";
import googleAnalytics from "@analytics/google-analytics";
import type { AnyPayload } from "@solid-primitives/analytics";
const ga = googleAnalytics({ measurementId: import.meta.env.VITE_GA_ID });
export const relayEvent = action(async (payload: AnyPayload) => {
"use server";
await ga.track?.({ payload, config: {}, abort: () => {} });
});
app.tsx
import { createAnalytics } from "@solid-primitives/analytics";
import { createSolidStartRelayPlugin } from "@solid-primitives/analytics/relay/solidstart";
import { relayEvent } from "./analytics.server";
const analytics = createAnalytics([
createSolidStartRelayPlugin(relayEvent, { events: ["track", "identify"] }),
]);

TanStack Start

import { createTanStackRelayPlugin } from "@solid-primitives/analytics/relay/tanstack";

createTanStackRelayPlugin wraps your TanStack Start server function and handles the { data } call-site signature automatically:

analytics.server.ts
import { createServerFn } from "@tanstack/start";
import googleAnalytics from "@analytics/google-analytics";
import type { AnyPayload } from "@solid-primitives/analytics";
const ga = googleAnalytics({ measurementId: process.env.GA_ID });
export const relayEvent = createServerFn({ method: "POST" })
.validator((d: unknown) => d as AnyPayload)
.handler(async ({ data: payload }) => {
await ga.track?.({ payload, config: {}, abort: () => {} });
});
app.tsx
import { createAnalytics } from "@solid-primitives/analytics";
import { createTanStackRelayPlugin } from "@solid-primitives/analytics/relay/tanstack";
import { relayEvent } from "./analytics.server";
const analytics = createAnalytics([
createTanStackRelayPlugin(relayEvent, { events: ["track", "identify"] }),
]);

drain()

All controls expose drain(): Promise<void>, which resolves once every currently in-flight plugin call has settled. Useful for imperative navigation:

await analytics.drain();
router.navigate("/checkout");

Events still in the queue (waiting for a plugin to initialize) are not awaited by drain(). In practice, plugins are ready long before a user navigates.


Controls reference

makeAnalytics, createAnalytics, and useAnalytics() all return the same control surface:

MethodDescription
page(properties?)Track a page view. Auto-fills path, url, title, and referrer from window.location in the browser.
track(event, properties?)Track a named event with optional properties.
identify(userId, traits?)Associate subsequent events with a user identity.
use(plugin)Register an additional plugin at any time.
reset()Stop the poll timer and discard all queued events.
drain()Promise<void> — resolves when all in-flight dispatches have settled.

createAnalytics and useAnalytics() additionally expose:

PropertyTypeDescription
pendingCountAccessor<number>Events waiting in the queue; non-zero while plugins are still initializing

How the queue works

Events fire immediately to all ready plugins. When one or more plugins are still initializing (async script load, consent check, etc.), events are held in a bounded FIFO queue and replayed in order once all plugins become ready. A poll timer (default: 500 ms) handles loaded() checks for external scripts.

Dispatch is sequential through the plugin list. A plugin can call abort() to prevent subsequent plugins from receiving that event.


Plugin interface

AnalyticsPlugin matches the analytics npm package plugin interface, so any plugin from the catalogue is compatible without modification.

import type { AnalyticsPlugin } from "@solid-primitives/analytics";
const myPlugin: AnalyticsPlugin = {
name: "my-service",
config: { apiKey: "abc123" },
// optional async setup (e.g. inject a script tag)
initialize: async ({ config }) => {
await loadScript(`https://cdn.example.com/sdk.js?key=${config.apiKey}`);
},
// polled until true before queued events are flushed
loaded: () => typeof window.myService !== "undefined",
page: ({ payload }) => { window.myService.page(payload.properties); },
track: ({ payload }) => { window.myService.track(payload.event, payload.properties); },
identify: ({ payload }) => { window.myService.identify(payload.userId, payload.traits); },
};

All event payloads carry a meta object with a unique request ID (rid) and a Unix timestamp (ts).


Options

type AnalyticsOptions = {
/** Maximum events to hold in the queue before discarding. Default: 100 */
queueLimit?: number;
/** Milliseconds between plugin-readiness polls. Default: 500 */
retryInterval?: number;
/**
* When set, switches to batch mode: events accumulate and flush on this
* interval (ms) rather than immediately.
*/
drainInterval?: number;
/**
* Maximum events dispatched per drain cycle. Only applies when
* `drainInterval` is set. Remaining events stay queued for the next cycle.
*/
drainSize?: number;
};

SSR

Both makeAnalytics and createAnalytics are isomorphic. On the server, page() omits browser-only defaults (url, referrer, title) since window is unavailable, but all other behaviour — plugin dispatch, queuing, drain() — works identically.

makeAnalyticsGuard / createAnalyticsGuard are no-ops on the server.


Changelog

See CHANGELOG.md

Solid Primitives 2High-quality reactive primitives for building applications in Solid2
Community
githubdiscord