Web analytics toolkit for Next.js + Supabase
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.
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).
- AI-Assisted Installation
- Quick Start
- Packages
- Manual Installation
- Authentication
- Error Tracking
- Data Lifecycle
- Geolocation
- Theming
- Configuration Reference
- Environment Variables
- Compatibility
- Development
- License
Run the setup CLI in an existing Next.js project with Supabase:
npx create-pulsekitThis installs all packages, scaffolds the dashboard route, injects the tracker into your layout, and writes the Supabase migration.
After running, complete the setup:
- 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> - Push the database migration:
npx supabase link npx supabase db push
- Start your dev server and visit
/admin/analytics
| 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
If you prefer setting things up manually instead of using the CLI:
npm install @pulsekit/core @pulsekit/next @pulsekit/reactThe 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 })();
});// 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.
// 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.
Copy the migration files from node_modules/@pulsekit/core/sql/ into your Supabase migrations directory and run npx supabase db push.
PulseKit includes a password-based authentication system to protect the dashboard.
PULSE_SECRETis your shared secret (minimum 16 characters)createPulseAuthHandlerprovides login (POST) and logout (DELETE) endpoints — it validates the password using timing-safe comparison and sets a signed httpOnly cookie<PulseAuthGate>wraps your dashboard page — it reads the cookie server-side and either renders the dashboard or shows a login formwithPulseAuthis a middleware wrapper for protecting API routes (refresh-aggregates, consolidate) — it accepts either a valid cookie or anAuthorization: Bearer <PULSE_SECRET>header (useful for cron jobs)
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).
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"PulseKit captures both client-side and server-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}.
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.
PulseKit uses a two-tier storage strategy: raw events for recent data and pre-computed aggregates for historical data.
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).
The consolidate endpoint is designed for periodic cron jobs. It:
- Rolls up pageview events older than
retentionDays(default: 30) intopulse_aggregates - 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.
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).
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.
PulseKit uses CSS custom properties for all visual styling. Import the stylesheet:
import "@pulsekit/react/pulse.css";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.
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 |
.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;
}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 |
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.
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.
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 |
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 |
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 |
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-vitalslibrary - Client-side errors (
window.onerror,unhandledrejection) - Browser timezone (stored in a
pulse_tzcookie)
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 |
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 |
| 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) |
| 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 |
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 packagesMIT