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
CRON_SECRET= # any random string, used by Vercel Cron
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).
4. If deploying to Vercel, add CRON_SECRET to your project environment variables to
enable automatic data aggregation and cleanup (configured in vercel.json).
- 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> CRON_SECRET=<a-random-string-for-cron-auth> - Push the database migration:
npx supabase link npx supabase db push
- If deploying to Vercel, add
CRON_SECRETto your project environment variables — see Scheduling - 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. They export both GET (for Vercel Cron) and POST (for manual triggers and the dashboard refresh button):
// app/api/pulse/refresh-aggregates/route.ts
import { createRefreshHandler, withPulseAuth } from "@pulsekit/next";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const handler = withPulseAuth(createRefreshHandler({ supabase }));
export const GET = handler;
export const POST = handler;// app/api/pulse/consolidate/route.ts
import { createConsolidateHandler, withPulseAuth } from "@pulsekit/next";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const handler = withPulseAuth(createConsolidateHandler({ supabase }));
export const GET = handler;
export const POST = handler;// 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: Bearer header as an alternative to cookies. The bearer token is checked against both PULSE_SECRET and CRON_SECRET environment variables.
Vercel Cron sets CRON_SECRET automatically on each request — see Scheduling below.
External cron services (cron-job.org, Upstash QStash, GitHub Actions, etc.) can use PULSE_SECRET directly:
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.
Both refresh and consolidate must run on a schedule — without this, the raw events table grows unbounded and aggregates are never populated.
The create-pulsekit CLI scaffolds a vercel.json with two cron jobs:
{
"crons": [
{ "path": "/api/pulse/refresh-aggregates", "schedule": "0 */6 * * *" },
{ "path": "/api/pulse/consolidate", "schedule": "0 3 * * *" }
]
}This refreshes aggregates every 6 hours and runs consolidation/cleanup nightly at 3 AM UTC. Vercel Cron is available on all plans including the free Hobby plan.
To enable it:
- Add a
CRON_SECRETenvironment variable to your Vercel project (any random string) - Deploy — Vercel reads
vercel.jsonand starts the schedules automatically
Vercel sends CRON_SECRET as an Authorization: Bearer header on each cron request, which withPulseAuth verifies automatically.
If you're not on Vercel, call the endpoints from any external scheduler with the Authorization header:
# Refresh aggregates (every 6 hours)
curl https://your-app.com/api/pulse/refresh-aggregates \
-H "Authorization: Bearer $PULSE_SECRET"
# Consolidate and cleanup (daily)
curl https://your-app.com/api/pulse/consolidate \
-H "Authorization: Bearer $PULSE_SECRET"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. Checks in order: pulse_auth cookie, Authorization: Bearer header against PULSE_SECRET, then against CRON_SECRET. Reads both 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" |
Traffic tab date range: "7d", "30d", or { from: string; to: string } (ISO dates) |
tab |
string |
"traffic" |
Active tab: "traffic", "vitals", "errors", "events", or "system" |
range |
"7d" | "30d" |
"7d" |
Timeframe for Vitals, Errors, and Events tabs (raw-event retention window) |
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 |
eventType |
string |
— | Events tab: filter by event type ("pageview", "error", etc.) |
eventPath |
string |
— | Events tab: filter by path |
eventSession |
string |
— | Events tab: filter by session ID |
eventPage |
number |
0 |
Events tab: page number for pagination (0-indexed) |
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) |
CRON_SECRET |
No | Server-only | Secret for Vercel Cron authentication (see Scheduling) |
| 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