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

@seneris/nosework

v0.2.0

Published

Privacy-focused, self-hosted analytics for your app suite

Readme

nosework

Privacy-focused, self-hosted analytics for your app suite.

Features

  • Cookieless tracking - No consent banners needed
  • City-level geolocation - Via Vercel's geo headers
  • GDPR compliant - No PII stored, IPs are hashed
  • Multi-site support - Track all your apps with one shared database
  • Full API - Query your analytics data programmatically

Architecture

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│   App 1     │  │   App 2     │  │   App N     │
│  (Next.js)  │  │  (Next.js)  │  │  (Next.js)  │
└──────┬──────┘  └──────┬──────┘  └──────┬──────┘
       │                │                │
       │  import { trackPageView }       │
       │  from '@seneris/nosework'       │
       │                │                │
       └────────────────┼────────────────┘
                        │
              ┌─────────▼─────────┐
              │  Shared Neon DB   │
              │  (PostgreSQL)     │
              └───────────────────┘

Each app imports nosework and writes directly to a shared PostgreSQL database. No intermediate service needed.

Why Each App Needs a Tracking Endpoint

The browser (client-side) cannot:

  • Connect directly to PostgreSQL
  • Access HTTP headers (IP address, User-Agent, geo data)

So each app needs a small API endpoint that:

  1. Receives tracking requests from the browser
  2. Reads IP/User-Agent/geo from HTTP headers
  3. Calls trackPageView() to write to the shared DB

This is typically ~20 lines of code per app.


Geolocation: Vercel Headers

Important: This package is designed for apps hosted on Vercel.

Vercel automatically adds geolocation headers to every request:

| Header | Example | Description | |--------|---------|-------------| | x-vercel-ip-country | US | Country code | | x-vercel-ip-country-region | CA | Region/state | | x-vercel-ip-city | San Francisco | City name |

These headers are:

  • Free - No third-party accounts or API keys needed
  • Automatic - Available on every request when deployed to Vercel
  • Accurate - Powered by Vercel's edge network

Local Development

Geo headers are not available in local development (localhost). Location data will be null when developing locally - this is expected and won't affect functionality.

Non-Vercel Hosting

If you're not using Vercel, you have options:

  1. Skip location data (everything else works fine)
  2. Use a GeoIP service/database and pass the data to trackPageView()
  3. Check if your hosting provider offers similar geo headers

Quick Start

1. Install

bun add @seneris/nosework

2. Set Environment Variables

Add to your .env:

ANALYTICS_DATABASE_URL="postgresql://user:pass@host/dbname"
ANALYTICS_SITE_ID="my-app"  # Unique identifier for this app

MoopySuite Users: Your analytics tables are in the MoopySuite database, so use:

ANALYTICS_DATABASE_URL="${DATABASE_URL}"  # Same as MoopySuite DB
# No ANALYTICS_SITE_ID needed - use MOOPY_CLIENT_ID instead

See MoopySuite Integration.

3. Add Tracking Endpoint

Create app/api/analytics/track/route.ts:

import { trackPageView } from '@seneris/nosework';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    const { url, referrer } = await request.json();

    await trackPageView({
      siteId: process.env.ANALYTICS_SITE_ID!,
      url,
      referrer,
      // For visitor hashing
      ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
      userAgent: request.headers.get('user-agent'),
      // Vercel geo headers (automatically provided when deployed)
      country: request.headers.get('x-vercel-ip-country'),
      countryCode: request.headers.get('x-vercel-ip-country'),
      region: request.headers.get('x-vercel-ip-country-region'),
      city: request.headers.get('x-vercel-ip-city'),
    });

    return NextResponse.json({ ok: true });
  } catch (error) {
    console.error('Analytics error:', error);
    return NextResponse.json({ ok: false }, { status: 500 });
  }
}

4. Add Client Component

Create components/Analytics.tsx:

'use client';

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

export function Analytics() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const initialLoad = useRef(true);

  useEffect(() => {
    // Track page view
    const trackPageView = () => {
      fetch('/api/analytics/track', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          url: window.location.href,
          referrer: initialLoad.current ? document.referrer : null,
        }),
        keepalive: true, // Ensures request completes even on navigation
      }).catch(() => {
        // Silently fail - analytics should never break the app
      });

      initialLoad.current = false;
    };

    trackPageView();
  }, [pathname, searchParams]);

  return null;
}

5. Add to Layout

In app/layout.tsx:

import { Analytics } from '@/components/Analytics';
import { Suspense } from 'react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {children}
        <Suspense fallback={null}>
          <Analytics />
        </Suspense>
      </body>
    </html>
  );
}

That's it! Your app is now tracking page views with location data.


Alternative: Middleware-Only Tracking

If you prefer not to use a client component, you can track in Next.js middleware. This is simpler but won't capture client-side navigations in SPAs.

Create middleware.ts:

import { trackPageView } from '@seneris/nosework';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  // Only track page requests, not API/static files
  const { pathname } = request.nextUrl;

  if (
    pathname.startsWith('/api') ||
    pathname.startsWith('/_next') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  // Fire and forget - don't block the response
  trackPageView({
    siteId: process.env.ANALYTICS_SITE_ID!,
    url: request.url,
    referrer: request.headers.get('referer'),
    ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
    userAgent: request.headers.get('user-agent'),
    country: request.headers.get('x-vercel-ip-country'),
    countryCode: request.headers.get('x-vercel-ip-country'),
    region: request.headers.get('x-vercel-ip-country-region'),
    city: request.headers.get('x-vercel-ip-city'),
  }).catch(() => {});

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Tradeoffs:

| Approach | Pros | Cons | |----------|------|------| | Client + API | Catches all navigations, including SPA | More setup (~2 files) | | Middleware only | Single file, simpler | Misses client-side navigations |


Tracking Custom Events

Beyond page views, you can track custom events:

import { trackEvent } from '@seneris/nosework';

// In an API route or server action
await trackEvent({
  siteId: process.env.ANALYTICS_SITE_ID!,
  name: 'signup',
  properties: { plan: 'pro', source: 'landing-page' },
  ip: request.headers.get('x-forwarded-for'),
  userAgent: request.headers.get('user-agent'),
  userId: user?.id, // Optional - link to your user
});

Common events to track:

  • signup - User registration
  • login - User authentication
  • purchase - Completed transaction
  • button_click - Important UI interactions
  • form_submit - Form completions
  • error - Client-side errors

Querying Analytics

Basic Stats

import { getStats } from '@seneris/nosework';

const stats = await getStats({
  siteId: 'my-app',
  startDate: new Date('2024-01-01'),
  endDate: new Date(),
});

// Returns:
// {
//   pageViews: 12345,
//   visitors: 5678,
//   sessions: 7890,
//   bounceRate: 42.5
// }

Top Pages

import { getTopPages } from '@seneris/nosework';

const pages = await getTopPages({
  siteId: 'my-app',
  startDate,
  endDate,
  limit: 10,
});

// Returns:
// [
//   { pathname: '/', pageViews: 5000, visitors: 3000 },
//   { pathname: '/pricing', pageViews: 2000, visitors: 1500 },
//   ...
// ]

Location Breakdown

import { getLocations } from '@seneris/nosework';

const locations = await getLocations({
  siteId: 'my-app',
  startDate,
  endDate,
  limit: 20,
});

// Returns:
// [
//   { country: 'United States', countryCode: 'US', city: 'New York', pageViews: 1000, visitors: 500 },
//   { country: 'Netherlands', countryCode: 'NL', city: 'Amsterdam', pageViews: 800, visitors: 400 },
//   ...
// ]

Traffic Sources

import { getReferrers } from '@seneris/nosework';

const referrers = await getReferrers({
  siteId: 'my-app',
  startDate,
  endDate,
  limit: 10,
});

Device/Browser Breakdown

import { getDevices } from '@seneris/nosework';

const devices = await getDevices({
  siteId: 'my-app',
  startDate,
  endDate,
});

Time Series (for charts)

import { getTimeSeries } from '@seneris/nosework';

const data = await getTimeSeries({
  siteId: 'my-app',
  startDate,
  endDate,
  interval: 'day', // 'hour' | 'day' | 'week' | 'month'
});

// Returns:
// [
//   { date: '2024-01-01', pageViews: 100, visitors: 50 },
//   { date: '2024-01-02', pageViews: 120, visitors: 60 },
//   ...
// ]

Privacy Design

nosework is designed to be privacy-friendly by default:

No Cookies

Visitors are identified by a hash of IP + User-Agent + daily salt. This hash rotates daily, so you can't track users across days (by design).

No PII Storage

  • IP addresses are never stored - only used for hashing
  • User-Agent strings are parsed into categories (e.g., "Chrome", "Windows") - raw strings are not stored
  • The visitor hash cannot be reversed to identify the original IP

Session Inference

Sessions are inferred using a 30-minute window hash. No session cookies needed.

Bot Filtering

Known bots (Googlebot, crawlers, etc.) are automatically flagged and excluded from statistics.

GDPR Compliance

Because no cookies are used and no PII is stored, you typically don't need:

  • Cookie consent banners
  • Privacy policy updates for analytics
  • Data processing agreements

Note: Consult with a legal professional for your specific situation.


API Reference

Tracking Functions

trackPageView(options)

Track a page view.

interface TrackPageViewOptions {
  siteId: string;          // Required: Your site identifier
  url: string;             // Required: Full URL of the page
  referrer?: string;       // Optional: Referring URL
  ip?: string;             // Optional: Visitor IP (for hashing)
  userAgent?: string;      // Optional: Browser user agent
  userId?: string;         // Optional: Your app's user ID
  // Geo data (from Vercel headers)
  country?: string;        // Optional: Country name
  countryCode?: string;    // Optional: Country code (e.g., "US")
  region?: string;         // Optional: Region/state
  city?: string;           // Optional: City name
}

trackEvent(options)

Track a custom event.

interface TrackEventOptions {
  siteId: string;          // Required: Your site identifier
  name: string;            // Required: Event name
  properties?: object;     // Optional: Event metadata
  url?: string;            // Optional: Page URL
  ip?: string;             // Optional: Visitor IP
  userAgent?: string;      // Optional: Browser user agent
  userId?: string;         // Optional: Your app's user ID
}

Query Functions

All query functions accept:

interface QueryOptions {
  siteId: string;
  startDate: Date;
  endDate: Date;
}

interface PaginatedQueryOptions extends QueryOptions {
  limit?: number;   // Default: 10-20 depending on function
  offset?: number;
}

| Function | Returns | |----------|---------| | getStats(options) | { pageViews, visitors, sessions, bounceRate } | | getTopPages(options) | [{ pathname, pageViews, visitors }] | | getLocations(options) | [{ country, countryCode, city, pageViews, visitors }] | | getReferrers(options) | [{ referrer, pageViews, visitors }] | | getDevices(options) | [{ device, browser, os, pageViews, visitors }] | | getTimeSeries(options) | [{ date, pageViews, visitors }] |

Utility Functions

| Function | Description | |----------|-------------| | getClient() | Get the Drizzle client for custom queries | | disconnect() | Disconnect from the database | | isBot(userAgent) | Check if a user-agent is a bot | | parseUserAgent(ua) | Parse a user-agent string | | cleanupOldSalts() | Remove daily salts older than 7 days |


Database Schema

The package uses these tables (created by MoopySuite migrations):

  • page_views - Individual page view events with location, device info
  • events - Custom events with properties
  • daily_salts - Rotating salts for visitor hashing (privacy)
  • analytics_errors - Individual error occurrences
  • error_groups - Aggregated errors by fingerprint

See src/schema.ts for the Drizzle schema definitions.


Limitations

  • Vercel hosting required for geo data - Location headers are provided by Vercel. Other hosts won't have geo data unless you add a third-party GeoIP service.
  • No local geo data - When running locally, geo headers are not available. Location will be null in development.

License

MIT