Skip to content

benoiteom/pulsekit

PulseKit

Web analytics toolkit for Next.js + Supabase

npm version license CI


PulseKit gives you a self-hosted analytics dashboard inside your Next.js app, backed by Supabase. Track page views, traffic sources, Web Vitals, errors, and visitor geography — no third-party scripts, no external services.

AI-Assisted Installation

Copy and paste this prompt into your AI coding assistant to install PulseKit:

Run `npx create-pulsekit` to install PulseKit web analytics into this Next.js + Supabase project.
It will install packages, scaffold API routes, inject the tracker into the layout, create the
dashboard page, set up error reporting, and write the Supabase migration. After it finishes:

1. Add these environment variables to .env.local (ask me for the values if needed):
   NEXT_PUBLIC_SUPABASE_URL=
   NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
   SUPABASE_SERVICE_ROLE_KEY=
   PULSE_SECRET=          # minimum 16 characters

2. Push the database migration:
   npx supabase link && npx supabase db push

3. If the project has middleware that protects routes, allow /api/pulse and /admin/analytics through (often found in lib/supabase/proxy.ts for a Next.js + Supabase project).

Table of Contents

Quick Start

Run the setup CLI in an existing Next.js project with Supabase:

npx create-pulsekit

This installs all packages, scaffolds the dashboard route, injects the tracker into your layout, and writes the Supabase migration.

After running, complete the setup:

  1. Add your environment variables to .env.local:
    NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>
    NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-anon-key>
    SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>
    PULSE_SECRET=<a-secret-at-least-16-characters>
    
  2. Push the database migration:
    npx supabase link
    npx supabase db push
  3. Start your dev server and visit /admin/analytics

Packages

Package Description
@pulsekit/core Core analytics queries, types, and SQL migrations
@pulsekit/next Next.js API route handlers and client-side tracker
@pulsekit/react React Server Components for the analytics dashboard
create-pulsekit CLI scaffolding tool

The dependency chain is: @pulsekit/core@pulsekit/next@pulsekit/react

Manual Installation

If you prefer setting things up manually instead of using the CLI:

npm install @pulsekit/core @pulsekit/next @pulsekit/react

1. Create the API routes

The ingestion route receives events from the tracker:

// app/api/pulse/route.ts
import { createPulseHandler } from "@pulsekit/next";
import { createClient } from "@supabase/supabase-js";
import type { NextRequest } from "next/server";

export const POST = (req: NextRequest) => {
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );

  return createPulseHandler({
    supabase,
    config: {
      siteId: "my-site",
      secret: process.env.PULSE_SECRET,
    },
  })(req);
};

The auth route handles dashboard login/logout (see Authentication):

// app/api/pulse/auth/route.ts
import { createPulseAuthHandler } from "@pulsekit/next";

const handler = createPulseAuthHandler({
  secret: process.env.PULSE_SECRET!,
});

export const POST = handler;
export const DELETE = handler;

The refresh-aggregates and consolidate routes power the data lifecycle:

// app/api/pulse/refresh-aggregates/route.ts
import { createRefreshHandler, withPulseAuth } from "@pulsekit/next";
import { createClient } from "@supabase/supabase-js";

export const POST = withPulseAuth(() => {
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
  return createRefreshHandler({ supabase })();
});
// app/api/pulse/consolidate/route.ts
import { createConsolidateHandler, withPulseAuth } from "@pulsekit/next";
import { createClient } from "@supabase/supabase-js";

export const POST = withPulseAuth(() => {
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
  return createConsolidateHandler({ supabase })();
});

2. Add the tracker to your layout

// components/pulse-tracker-wrapper.tsx
import { PulseTracker } from "@pulsekit/next/client";
import { createPulseIngestionToken } from "@pulsekit/next";
import { connection } from "next/server";

export default async function PulseTrackerWrapper() {
  await connection();
  const token = process.env.PULSE_SECRET
    ? await createPulseIngestionToken(process.env.PULSE_SECRET)
    : undefined;

  return (
    <PulseTracker
      excludePaths={["/admin/analytics"]}
      token={token}
    />
  );
}
// app/layout.tsx
import { Suspense } from "react";
import PulseTrackerWrapper from "@/components/pulse-tracker-wrapper";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Suspense>
          <PulseTrackerWrapper />
        </Suspense>
      </body>
    </html>
  );
}

@pulsekit/next has two import paths: @pulsekit/next for server-side exports (handlers, auth, error reporter) and @pulsekit/next/client for the client-side PulseTracker component.

3. Add the dashboard page

// app/admin/analytics/page.tsx
import { Suspense } from "react";
import "@pulsekit/react/pulse.css";

export default function AnalyticsPage({
  searchParams,
}: {
  searchParams: Promise<{ from?: string; to?: string }>;
}) {
  return (
    <Suspense fallback={<div>Loading dashboard...</div>}>
      <Dashboard searchParams={searchParams} />
    </Suspense>
  );
}

// Separate async component — renders dynamically
import { PulseDashboard, PulseAuthGate } from "@pulsekit/react";
import { getPulseTimezone } from "@pulsekit/next";
import { createClient } from "@supabase/supabase-js";
import type { Timeframe } from "@pulsekit/core";

async function Dashboard({
  searchParams,
}: {
  searchParams: Promise<{ from?: string; to?: string }>;
}) {
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  const { from, to } = await searchParams;
  const timeframe: Timeframe = from && to ? { from, to } : "7d";
  const timezone = await getPulseTimezone();

  return (
    <PulseAuthGate secret={process.env.PULSE_SECRET!}>
      <PulseDashboard
        supabase={supabase}
        siteId="my-site"
        timeframe={timeframe}
        timezone={timezone}
      />
    </PulseAuthGate>
  );
}

The dashboard page uses SUPABASE_SERVICE_ROLE_KEY because the security hardening migration restricts read access from the anon role.

4. Run the SQL migrations

Copy the migration files from node_modules/@pulsekit/core/sql/ into your Supabase migrations directory and run npx supabase db push.

Authentication

PulseKit includes a password-based authentication system to protect the dashboard.

How it works

  1. PULSE_SECRET is your shared secret (minimum 16 characters)
  2. createPulseAuthHandler provides login (POST) and logout (DELETE) endpoints — it validates the password using timing-safe comparison and sets a signed httpOnly cookie
  3. <PulseAuthGate> wraps your dashboard page — it reads the cookie server-side and either renders the dashboard or shows a login form
  4. withPulseAuth is a middleware wrapper for protecting API routes (refresh-aggregates, consolidate) — it accepts either a valid cookie or an Authorization: Bearer <PULSE_SECRET> header (useful for cron jobs)

Ingestion token

When secret is set on createPulseHandler, all tracking requests must include a valid x-pulse-token header. Generate a token server-side and pass it to the tracker:

// app/layout.tsx
import { createPulseIngestionToken } from "@pulsekit/next";

const token = await createPulseIngestionToken(process.env.PULSE_SECRET!);
// <PulseTracker token={token} />

Tokens are HMAC-SHA256 signed and expire after 24 hours by default (configurable via the second argument in milliseconds).

Calling protected routes from cron jobs

Routes wrapped with withPulseAuth accept an Authorization header as an alternative to cookies:

curl -X POST https://your-app.com/api/pulse/consolidate \
  -H "Authorization: Bearer $PULSE_SECRET"

Error Tracking

PulseKit captures both client-side and server-side errors.

Client-side errors

The <PulseTracker> component automatically captures window.onerror and unhandledrejection events. Errors are deduplicated by fingerprint (message|source|lineno) and capped at 10 unique errors per page session to prevent flooding. Disable with captureErrors={false}.

Server-side errors

Use createPulseErrorReporter in your Next.js instrumentation file to capture server-side errors:

// instrumentation.ts
import { createPulseErrorReporter } from "@pulsekit/next";
import { createClient } from "@supabase/supabase-js";

let reporter: ReturnType<typeof createPulseErrorReporter> | undefined;

export const onRequestError = (
  ...args: Parameters<ReturnType<typeof createPulseErrorReporter>>
) => {
  if (!process.env.NEXT_PUBLIC_SUPABASE_URL) return;
  reporter ??= createPulseErrorReporter({
    supabase: createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL,
      process.env.SUPABASE_SERVICE_ROLE_KEY!
    ),
  });
  return reporter(...args);
};

The error reporter captures the error message, stack trace, HTTP method, route path, and route type. It will never throw — errors during reporting are silently caught so they don't break your app.

Data Lifecycle

PulseKit uses a two-tier storage strategy: raw events for recent data and pre-computed aggregates for historical data.

Refresh aggregates

The refresh-aggregates endpoint rolls up recent pageview events into daily aggregates in the pulse_aggregates table. The <RefreshButton> in the dashboard UI triggers this. Configure how far back to refresh with the daysBack option (default: 7).

Consolidate and cleanup

The consolidate endpoint is designed for periodic cron jobs. It:

  1. Rolls up pageview events older than retentionDays (default: 30) into pulse_aggregates
  2. Deletes all raw events older than retentionDays
createConsolidateHandler({ supabase, retentionDays: 30 })

The dashboard automatically queries both raw events and aggregates seamlessly, so data remains continuous even after old events are deleted.

Geolocation

PulseKit reads Vercel's geolocation headers (x-vercel-ip-country, x-vercel-ip-city, x-vercel-ip-latitude, x-vercel-ip-longitude, etc.) to capture visitor location data. This works automatically on all Vercel plans at no extra cost.

If you're not on Vercel, geolocation data will be empty unless your hosting provider populates these same headers (e.g., via a reverse proxy or CDN).

Timezone detection

The <PulseTracker> sets a pulse_tz cookie with the visitor's browser timezone. Read it server-side with getPulseTimezone() and pass it to <PulseDashboard> so that the charts bucket data by the visitor's local date.

Theming

PulseKit uses CSS custom properties for all visual styling. Import the stylesheet:

import "@pulsekit/react/pulse.css";

Automatic shadcn/ui integration

Several variables fall back to shadcn/ui CSS variables, so PulseKit automatically picks up your project's theme if you use shadcn/ui. No extra configuration needed.

Custom properties reference

Override any of these on :root or a parent element to customize the dashboard appearance:

Brand

Variable Default Description
--pulse-brand #7C3AED Primary brand color
--pulse-brand-light #8B5CF6 Lighter brand variant (hover states)

Surfaces and text

Variable Default Description
--pulse-bg hsl(var(--card, 0 0% 100%)) Card/surface background
--pulse-fg hsl(var(--card-foreground, 0 0% 3.9%)) Primary text color
--pulse-fg-muted hsl(var(--muted-foreground, 0 0% 45.1%)) Secondary/muted text
--pulse-border hsl(var(--border, 0 0% 89.8%)) Border color
--pulse-border-light #f3f4f6 Lighter border (table rows)
--pulse-radius var(--radius, 0.5rem) Border radius

Charts

Variable Default Description
--pulse-chart-1 hsl(var(--chart-1, 262 83% 58%)) Primary chart color (views)
--pulse-chart-2 hsl(var(--chart-2, 187 86% 53%)) Secondary chart color (unique visitors)

Map

Variable Default Description
--pulse-map-land #f0f0f0 Land fill color
--pulse-map-land-stroke #d1d5db Land border color
--pulse-map-marker rgba(124, 58, 237, 0.55) Marker fill
--pulse-map-marker-stroke rgba(124, 58, 237, 0.85) Marker stroke

Web Vitals badges

Variable Default Description
--pulse-vital-good-bg #f0fdf4 "Good" badge background
--pulse-vital-good-fg #15803d "Good" badge text
--pulse-vital-warn-bg #fefce8 "Needs improvement" badge background
--pulse-vital-warn-fg #a16207 "Needs improvement" badge text
--pulse-vital-poor-bg #fef2f2 "Poor" badge background
--pulse-vital-poor-fg #dc2626 "Poor" badge text

Other

Variable Default Description
--pulse-kpi-bg #faf5ff KPI card background
--pulse-btn-border hsl(var(--border, 0 0% 89.8%)) Button border

Example: dark theme override

.dark {
  --pulse-bg: #1e1e2e;
  --pulse-fg: #cdd6f4;
  --pulse-fg-muted: #a6adc8;
  --pulse-border: #313244;
  --pulse-border-light: #45475a;
  --pulse-kpi-bg: #313244;
  --pulse-map-land: #313244;
  --pulse-map-land-stroke: #45475a;
}

Configuration Reference

createPulseHandler({ supabase, config? })

Creates the event ingestion API route handler.

Option Type Default Description
supabase SupabaseClient Supabase client instance (required)
config.allowedOrigins string[] all origins CORS origin whitelist. Supports exact match, "*", and subdomain wildcards like "*.example.com"
config.ignorePaths string[] [] Paths to silently ignore (returns 200 but doesn't store)
config.siteId string "default" Default site ID for multi-tenant setups
config.rateLimit number 30 Max requests per IP per window
config.rateLimitWindow number 60 Rate limit window in seconds
config.secret string If set, requires a valid x-pulse-token header on requests
config.onError (error: unknown) => void Called on DB insert failure

createPulseAuthHandler({ secret, cookieMaxAge? })

Creates login/logout API route handler.

Option Type Default Description
secret string Shared secret, minimum 16 characters (required)
cookieMaxAge number 604800 (7 days) Auth cookie max-age in seconds

Login is rate limited to 5 attempts per 60 seconds per IP.

withPulseAuth(handler)

Wraps a Next.js route handler with auth protection. Accepts either a valid pulse_auth cookie or Authorization: Bearer <secret> header. Reads PULSE_SECRET from process.env.

createRefreshHandler({ supabase, daysBack? })

Creates the aggregate refresh API route handler.

Option Type Default Description
supabase SupabaseClient Supabase client with service role key (required)
daysBack number 7 Number of days to refresh

createConsolidateHandler({ supabase, retentionDays? })

Creates the consolidation/cleanup API route handler.

Option Type Default Description
supabase SupabaseClient Supabase client with service role key (required)
retentionDays number 30 Events older than this are aggregated and deleted

createPulseErrorReporter({ supabase, siteId? })

Creates a Next.js onRequestError handler for server-side error tracking.

Option Type Default Description
supabase SupabaseClient Supabase client (required)
siteId string "default" Site ID for the error events

<PulseTracker />

Client component that tracks page views, Web Vitals, and errors.

Prop Type Default Description
endpoint string "/api/pulse" API route URL
excludePaths string[] [] Paths to skip tracking
captureErrors boolean true Capture client-side JS errors
errorLimit number 10 Max unique errors per page session
token string Signed ingestion token (from createPulseIngestionToken)
onError (error: unknown) => void Called on tracking request failure

What it tracks automatically:

  • Page views on route changes
  • Traffic sources via document.referrer (stored as hostname only for privacy)
  • Web Vitals (LCP, INP, CLS, FCP, TTFB) via the web-vitals library
  • Client-side errors (window.onerror, unhandledrejection)
  • Browser timezone (stored in a pulse_tz cookie)

<PulseDashboard />

React Server Component that renders the full analytics dashboard.

Prop Type Default Description
supabase SupabaseClient Supabase client with service role key (required)
siteId string Site ID to query (required)
timeframe Timeframe "7d" "7d", "30d", or { from: string; to: string } (ISO dates)
timezone string "UTC" IANA timezone for date bucketing
refreshEndpoint string "/api/pulse/refresh-aggregates" Endpoint for the refresh button
onError (error: unknown) => void Called on data query failure

<PulseAuthGate />

React Server Component that protects the dashboard with password auth.

Prop Type Default Description
children ReactNode Dashboard content to protect (required)
secret string PULSE_SECRET value (required)
authEndpoint string "/api/pulse/auth" Auth API endpoint

Environment Variables

Variable Required Visibility Description
NEXT_PUBLIC_SUPABASE_URL Yes Public Supabase project URL
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY Yes Public Supabase anon/publishable key
SUPABASE_SERVICE_ROLE_KEY Yes Server-only Supabase service role key (for dashboard queries and admin routes)
PULSE_SECRET Yes Server-only Shared secret for auth and ingestion tokens (minimum 16 characters)

Compatibility

Dependency Tested Versions
Node.js 18, 20, 22
Next.js 14.x, 15.x, 16.x
React 18.x, 19.x
Supabase JS 2.x

Development

This is a pnpm monorepo using Turborepo.

pnpm install     # Install all dependencies
pnpm build       # Build all packages
pnpm dev         # Watch mode
pnpm test        # Run all tests
pnpm lint        # Run ESLint
pnpm clean       # Remove dist/ from all packages

License

MIT

About

No description, website, or topics provided.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors