npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stetcms/analytics

v0.2.0

Published

Cookieless, type-safe product analytics routed through your own backend. Part of Stet, the CMS for marketing and engineering.

Downloads

303

Readme

@stetcms/analytics

CI Docs License

Product analytics for apps built on Stet, the CMS where marketing owns the content model and engineering gets a typed client generated from it. Pageviews and your own typed events, routed through your backend instead of a third-party endpoint.

  • No cookies, so no consent banner.
  • No third-party origin in the page, so nothing for a blocker to match on.
  • Readers' addresses and user agents never leave your infrastructure.
  • One tracking plan types the browser calls, validates the server ones, and becomes the event list your content team builds dashboards from.

The analytics guide covers how this fits with the dashboards your content team builds on the same events.

Install

npm install @stetcms/analytics

1. Declare the tracking plan

The plan goes in stet.config.ts at the project root, the one file @stetcms/vite and the stet CLI read for everything (see @stetcms/config). Props take any Standard Schema validator, so Zod, Valibot and ArkType all work.

import { defineAnalytics, event } from '@stetcms/analytics';
import { defineStet } from '@stetcms/config';
import { z } from 'zod';

export default defineStet({
  analytics: defineAnalytics({
    events: {
      signup: event({ plan: z.enum(['free', 'paid']) }),
      checkout: {
        started: event(),
        completed: event({ total: z.number(), coupon: z.string().optional() }),
      },
    },
  }),
});

Nested events track under dot-joined names (checkout.completed). The file carries no secrets: STET_API_KEY and STET_ORIGIN are read from the environment, so it is safe to commit.

2. Mount the route

One route in your own app. It validates against the plan, adds what only your backend can see, and forwards to Stet with your organization API key.

import { createAnalyticsHandler } from '@stetcms/analytics/server';
import config from '../stet.config';

export const POST = createAnalyticsHandler(config.analytics, {
  // Called per request: whatever you return is attached to every event in the
  // batch, and overrules anything the browser claimed.
  context: async (request) => ({ userId: (await session(request))?.userId }),
});

3. Track from the browser

The client only ever talks to the route above, so it needs no key.

import { createAnalytics } from '@stetcms/analytics/client';
import type config from '../stet.config';

export const analytics = createAnalytics<(typeof config)['analytics']>({
  endpoint: '/api/analytics',
});

analytics.track('checkout.completed', { total: 42 });

track is typed from the plan: a misspelled name, a missing prop or a wrong type fails the build. It never throws and never rejects, so a tracking mistake cannot break the page. Events batch and flush every two seconds, when twenty are queued, and when the page is hidden or unloaded.

4. Track from your server too

A signup or a subscription belongs to your backend, and recording it there is what stops an ad blocker or a closed tab losing it. The same client does this: it guards its listeners behind a window check, so it runs anywhere fetch does. Point it at an absolute URL and it posts to the route you already mounted, which keeps your key in one place.

const analytics = createAnalytics<(typeof config)['analytics']>({
  endpoint: `${process.env.APP_URL}/api/analytics`,
  context: { userId },
  autoPageviews: false,
});

analytics.track('subscription.started', { plan: 'paid' });
await analytics.flush();

context is fixed when the client is built, so build one per unit of work rather than let two concurrent requests stamp each other's identity on their events. Nothing on a server fires pagehide, so call flush() yourself; on Cloudflare Workers, waitUntil(analytics.flush()) sends it without holding up the response.

Pageviews

On a site where every navigation is a real page load — plain HTML, Astro without view transitions, Rails, Django — leave autoPageviews on its default and you are done.

In a single-page app, turn it off and let your router say what a navigation is. The fallback patches history.pushState, which cannot see the replaceState that routers use for redirects and search-parameter changes, so it both misses views and reports the wrong URL for others. Your router already knows; ask it.

A repeated view of the same URL counts once either way, so the double-invoked effects of React Strict Mode do not inflate anything.

Pass the router's parameterized template as route when it exposes one. Stet then groups /blog/first and /blog/second under /blog/[slug]. Templates are framework-native: $slug, [slug], and :slug all work. When route is absent, Stet groups by the concrete pathname as before.

export const analytics = createAnalytics<(typeof config)['analytics']>({
  endpoint: '/api/analytics',
  autoPageviews: false,
});

Then mount one of these at the root, so every page is counted rather than only the routes that happen to import the client.

Pass the router's URL, never let pageview() read window.location. By the time your effect or callback runs, the router has advanced and window.location may not have, so a bare analytics.pageview() labels the view with the previous page — and the same-URL guard then quietly drops every other one. Every snippet below passes it explicitly for that reason.

TanStack Router

import { useLocation, useRouterState } from '@tanstack/react-router';
import { useEffect } from 'react';

export function usePageviews() {
  const location = useLocation();
  const route = useRouterState({
    select: (state) => state.matches.at(-1)?.fullPath,
  });
  useEffect(() => {
    analytics.pageview(`${window.location.origin}${location.href}`, { route });
  }, [location.href, route]);
}

Next.js (App Router)

'use client';

import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';

export function Pageviews() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  useEffect(() => {
    const query = searchParams.toString();
    analytics.pageview(`${window.location.origin}${pathname}${query === '' ? '' : `?${query}`}`);
  }, [pathname, searchParams]);
  return null;
}

Render it in app/layout.tsx. useSearchParams opts the subtree into client rendering, so wrap it in <Suspense> to keep the rest of the layout static. The App Router does not expose its route template to client components, so this falls back to the concrete pathname unless your app supplies one.

React Router

import { useEffect } from 'react';
import { useLocation } from 'react-router';

export function Pageviews() {
  const location = useLocation();
  useEffect(() => {
    analytics.pageview(`${window.location.origin}${location.pathname}${location.search}`);
  }, [location.pathname, location.search]);
  return null;
}

SvelteKit

In +layout.svelte:

<script>
  import { afterNavigate } from '$app/navigation';
  afterNavigate(({ to }) => analytics.pageview(to?.url.href, { route: to?.route.id ?? undefined }));
</script>

Nuxt

In a client-only plugin, plugins/analytics.client.ts:

export default defineNuxtPlugin(() => {
  useRouter().afterEach((to) => {
    const route = to.matched.at(-1)?.path;
    analytics.pageview(`${window.location.origin}${to.fullPath}`, { route });
  });
});

Astro

Astro navigations are full-page loads, so the default is already correct. With view transitions enabled, render the current pattern and read it after each page swap:

<meta name="stet-route" content={Astro.routePattern} />

<script>
  import { analytics } from '../analytics';

  document.addEventListener('astro:page-load', () => {
    const route = document.querySelector('meta[name="stet-route"]')?.getAttribute('content');
    analytics.pageview(window.location.href, { route: route ?? undefined });
  });
</script>

Astro 5 exposes the template as Astro.routePattern while rendering. The meta element is replaced during navigation, so the listener reads the new pattern.

Context vs metadata

Two different trust models, worth keeping straight:

  • metadata is derived by the handler from the request. The browser never sends it and cannot influence it.
  • context is merged. The browser proposes, and your handler overrules it per key.

So a key your handler does not set stays exactly as the browser sent it, and anyone can post whatever they like to your route. Treat a context key as trustworthy only if your handler sets it. userId in the example above is safe because the handler resolves it from the session; had it come from the browser, it would be a claim rather than a fact.

Returning undefined for a key leaves the browser's value in place rather than deleting it, so a missing session reads as "unknown". Pass null to state positively that there is nobody signed in.

Default metadata

Derived by the handler from the request the browser made to you. The raw address and user agent are used and discarded there; only what is listed here is forwarded.

| Field | From | | --------------------------- | ----------------------------------------------------------------------- | | country, region, city | request.cf on Cloudflare, cf-ipcountry or x-vercel-ip-* elsewhere | | browser, os, device | sec-ch-ua* client hints, falling back to the agent string | | visitor | SHA-256(date, salt, address, user agent), truncated |

The visitor digest covers the date, so the same reader is a different id tomorrow: uniques are countable within a day and nothing can be joined across days, by anyone, including us. salt defaults to the request's host, which also stops ids being comparable across sites you run.

Traffic that announces itself as automation is answered 200 and discarded, because a 4xx only teaches a crawler to retry.

API

@stetcms/analytics

defineAnalytics, event, validateEvent, flattenEvents, resolveEvent, parseClientBatch, and the types behind them. Isomorphic.

@stetcms/analytics/sync

syncTrackingPlan(options) publishes the plan to Stet. Called for you by @stetcms/vite on dev-server and build start, and by stet sync; you rarely call it yourself.

@stetcms/analytics/client

createAnalytics(options){ track, pageview, setContext, flush }. pageview(url?, options?: { route? }) keeps the concrete URL for deduplication and uses the optional framework route template for grouping.

| Option | Default | Meaning | | --------------- | -------- | ------------------------------------------ | | endpoint | required | The route you mounted the handler on | | context | {} | Props attached to every batch | | flushInterval | 2000 | Milliseconds a partial batch waits | | maxBatchSize | 20 | Send immediately once this many are queued | | autoPageviews | true | Pageviews on load and history navigation |

@stetcms/analytics/server

createAnalyticsHandler(plan, options)(request: Request) => Promise<Response>.

| Option | Default | Meaning | | --------- | ----------------------------- | ---------------------------------------------------------------- | | context | {} | Props, or a function of the request, that win over the browser's | | origin | STET_ORIGIN, then the cloud | Stet deployment to forward to | | apiKey | STET_API_KEY | Organization API key | | enabled | whether a key resolved | Whether to forward batches at all | | salt | the request's host | Mixed into the visitor digest | | onError | console.error | Called when a batch cannot be forwarded |

Responses: 200 { accepted } on success, 400 for a malformed batch or an event that does not match the plan, 502 when Stet could not be reached. An event outside the plan fails loudly rather than being dropped quietly, so a typo surfaces the first time it runs.

Keeping development out of your analytics

The same organization key generates your content client, so on a developer's machine it is present and real, and every page they load would otherwise land in the project your dashboards read. enabled is the switch, kept separate from the key for exactly that reason:

const handler = createAnalyticsHandler(plan, {
  enabled: process.env.ANALYTICS_ENABLED !== 'false',
});

Test for the off value rather than the on one, so an environment that never sets the variable records instead of silently dropping. A disabled handler answers 200 { accepted: 0 } and still checks each batch against the plan, so a typo'd event is still a 400 while you are working on it. Leave it on locally, point STET_ORIGIN at a Stet of your own, and your dashboard fills up with your own traffic instead.

Development

pnpm test   # Vitest
pnpm tc     # Type check
pnpm build  # vp pack

examples/tanstack runs this package against a local Stet; see its README.

License

Apache-2.0