Visitor Visibility & Action Tracking – UI Implementation Plan
This plan covers the frontend/client-side implementation for the visibility tracking system, following existing codebase patterns.
1. Visitor Identity Management
1.1 VisitorId Cookie Middleware
Extend middleware.ts to set/read a visitorId cookie for all requests:
middleware.ts additions:
- Generate v4 UUID for anonymous visitors on first request
- For authenticated users: hash the userId (from session) as visitorId
- Set cookie: `x-visitor-id` (httpOnly: false so client JS can read it)
- Forward `x-visitor-id` header to origin for API routes
Pattern alignment: Follows existing middleware auth pattern but adds identity tracking.
1.2 VisitorId Client Utility
Create src/lib/visitor/getVisitorId.ts:
- Read
x-visitor-idfrom document.cookie - Fallback: generate & store UUID in localStorage (edge case where cookie fails)
- Export
getVisitorId(): string
2. Custom Hooks
2.1 usePresenceHeartbeat
File: src/hooks/usePresenceHeartbeat.ts
Pattern: Similar to usePageTransition – uses useEffect, useRef, useCallback
interface UsePresenceHeartbeatOptions {
intervalMs?: number; // default from env: PRESENCE_HEARTBEAT_SECONDS * 1000
enabled?: boolean;
}
function usePresenceHeartbeat(options?: UsePresenceHeartbeatOptions): void
Implementation:
- On mount: send initial presence ping
setIntervalfor heartbeat (default 60s)visibilitychangelistener: ping on tab focus, mark leaving on hidebeforeunloadlistener: sendBeacon for final presence update- Cleanup: clear interval, remove listeners
API call:
POST /api/presence
Body: { route: pathname, title: document.title, ts: Date.now() }
Response: { serverTs: number }
Dependencies: Uses usePathname() from next/navigation, getVisitorId() utility.
2.2 useEventTracker
File: src/hooks/useEventTracker.ts
Pattern: Returns a memoized emit function, similar to useHover pattern.
type EventType = 'page_view' | 'nav' | 'click' | 'form_submit' | 'chat_message' | 'download';
interface TrackEvent {
type: EventType;
route?: string;
referrer?: string;
payload?: Record<string, unknown>; // size-capped by API
}
function useEventTracker(): {
track: (event: TrackEvent) => void;
flush: () => Promise<void>;
}
Implementation:
- Internal queue (useRef) for batching events
- Auto-flush when queue reaches 20 events or after 5s debounce
track()adds event with timestamp to queueflush()sends batch to API, clears queuebeforeunload: sendBeacon for remaining events
API call:
POST /api/events
Body: { events: TrackEvent[] }
2.3 usePageViewTracker
File: src/hooks/usePageViewTracker.ts
Pattern: Composition hook that uses useEventTracker + usePathname.
function usePageViewTracker(): void
Implementation:
- Track
page_viewon pathname change - Include
referrerfromdocument.referreron initial load - Debounce rapid route changes (200ms)
3. Context Provider
3.1 VisitorProvider
File: src/context/VisitorProvider.tsx
Pattern: Follows AdminProvider pattern – wraps app, provides context.
interface VisitorContextValue {
visitorId: string | null;
isPresenceEnabled: boolean;
isEventsEnabled: boolean;
track: (event: TrackEvent) => void;
}
const VisitorContext = createContext<VisitorContextValue | null>(null);
function VisitorProvider({ children, config }: {
children: React.ReactNode;
config: { presenceEnabled: boolean; eventsEnabled: boolean };
}): JSX.Element
function useVisitor(): VisitorContextValue
Integration point: Wrap in src/app/layout.tsx after existing providers, conditional on env flags.
4. Layout Integration
4.1 Root Layout Changes
File: src/app/layout.tsx
// Add after existing providers, before children
{process.env.NEXT_PUBLIC_PRESENCE_ENABLED === 'true' && (
<VisitorProvider config={{
presenceEnabled: process.env.NEXT_PUBLIC_PRESENCE_ENABLED === 'true',
eventsEnabled: process.env.NEXT_PUBLIC_EVENTS_ENABLED === 'true',
}}>
<PresenceHeartbeat />
<PageViewTracker />
{children}
</VisitorProvider>
)}
4.2 Headless Tracker Components
File: src/components/tracking/PresenceHeartbeat.tsx
'use client';
// Renders null, just runs the hook
function PresenceHeartbeat(): null {
usePresenceHeartbeat();
return null;
}
File: src/components/tracking/PageViewTracker.tsx
'use client';
function PageViewTracker(): null {
usePageViewTracker();
return null;
}
5. Event Instrumentation
5.1 Chat Integration
File: src/components/chat/ChatInput.tsx (or wherever chat submit lives)
Add to existing submit handler:
const { track } = useVisitor();
// After successful message send:
track({ type: 'chat_message', payload: { messageLength: message.length } });
5.2 Download Tracking
File: Create src/components/TrackedDownloadLink.tsx
interface TrackedDownloadLinkProps {
href: string;
filename: string;
children: React.ReactNode;
className?: string;
}
function TrackedDownloadLink({ href, filename, children, className }: TrackedDownloadLinkProps) {
const { track } = useVisitor();
const handleClick = () => {
track({ type: 'download', payload: { filename, href } });
};
return (
<a href={href} download={filename} onClick={handleClick} className={className}>
{children}
</a>
);
}
5.3 Key Button Clicks
For important CTAs, add tracking in onClick handlers:
track({ type: 'click', payload: { element: 'contact_cta', location: 'header' } });
6. Admin UI – Online Visitors Dashboard
6.1 New Admin Page
File: src/app/admin/visitors/page.tsx
Pattern: Follows PostsTable pattern – server prefetch, client refresh.
export default async function VisitorsPage() {
const onlineVisitors = await getOnlineVisitors();
const recentEvents = await getRecentEvents({ limit: 50 });
return (
<div className="space-y-8">
<OnlineVisitorsPanel initialData={onlineVisitors} />
<RecentEventsTable initialData={recentEvents} />
</div>
);
}
6.2 OnlineVisitorsPanel Component
File: src/components/admin/OnlineVisitorsPanel.tsx
'use client';
interface OnlineVisitor {
visitorId: string;
route: string;
lastSeen: number;
uaHash: string;
geo?: string;
}
interface OnlineVisitorsPanelProps {
initialData: OnlineVisitor[];
}
function OnlineVisitorsPanel({ initialData }: OnlineVisitorsPanelProps) {
const [visitors, setVisitors] = useState(initialData);
const [isRefreshing, setIsRefreshing] = useState(false);
// Auto-refresh every 30s
useEffect(() => {
const interval = setInterval(async () => {
const res = await fetch('/api/admin/online');
if (res.ok) {
const data = await res.json();
setVisitors(data.visitors);
}
}, 30_000);
return () => clearInterval(interval);
}, []);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
{visitors.length} Online Now
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{visitors.map((v) => (
<div key={v.visitorId} className="flex justify-between text-sm">
<span className="font-mono text-xs text-muted-foreground">
{v.visitorId.slice(0, 8)}...
</span>
<span>{v.route}</span>
<span className="text-muted-foreground">
{formatDistanceToNow(v.lastSeen)} ago
</span>
</div>
))}
{visitors.length === 0 && (
<p className="text-muted-foreground text-sm">No visitors online</p>
)}
</div>
</CardContent>
</Card>
);
}
6.3 RecentEventsTable Component
File: src/components/admin/RecentEventsTable.tsx
'use client';
interface TrackedEvent {
visitorId: string;
ts: number;
type: EventType;
route: string;
referrer?: string;
payload?: Record<string, unknown>;
}
interface RecentEventsTableProps {
initialData: TrackedEvent[];
}
function RecentEventsTable({ initialData }: RecentEventsTableProps) {
const [events, setEvents] = useState(initialData);
const [filter, setFilter] = useState<EventType | 'all'>('all');
const filteredEvents = filter === 'all'
? events
: events.filter(e => e.type === filter);
return (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<div className="flex gap-2">
{(['all', 'page_view', 'chat_message', 'download', 'click'] as const).map((t) => (
<Button
key={t}
variant={filter === t ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter(t)}
>
{t === 'all' ? 'All' : t.replace('_', ' ')}
</Button>
))}
</div>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Time</th>
<th className="text-left py-2">Type</th>
<th className="text-left py-2">Route</th>
<th className="text-left py-2">Visitor</th>
</tr>
</thead>
<tbody>
{filteredEvents.map((event, i) => (
<tr key={`${event.visitorId}-${event.ts}-${i}`} className="border-b">
<td className="py-2">{format(event.ts, 'HH:mm:ss')}</td>
<td className="py-2">
<EventTypeBadge type={event.type} />
</td>
<td className="py-2 font-mono text-xs">{event.route}</td>
<td className="py-2 font-mono text-xs text-muted-foreground">
{event.visitorId.slice(0, 8)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);
}
6.4 EventTypeBadge Component
File: src/components/admin/EventTypeBadge.tsx
const eventTypeColors: Record<EventType, string> = {
page_view: 'bg-blue-500/20 text-blue-400',
nav: 'bg-gray-500/20 text-gray-400',
click: 'bg-yellow-500/20 text-yellow-400',
form_submit: 'bg-purple-500/20 text-purple-400',
chat_message: 'bg-green-500/20 text-green-400',
download: 'bg-orange-500/20 text-orange-400',
};
function EventTypeBadge({ type }: { type: EventType }) {
return (
<span className={cn('px-2 py-0.5 rounded text-xs', eventTypeColors[type])}>
{type.replace('_', ' ')}
</span>
);
}
6.5 Admin Header Integration
File: src/components/AdminHeader.tsx
Add navigation link to existing admin header:
{ href: '/admin/visitors', label: 'Visitors', icon: Users }
7. API Routes (Client-Facing)
7.1 Presence API
File: src/app/api/presence/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { updatePresence } from '@/server/visitor/presence-store';
const presenceSchema = z.object({
route: z.string().max(500),
title: z.string().max(200),
ts: z.number(),
});
export async function POST(request: NextRequest) {
const visitorId = request.headers.get('x-visitor-id');
if (!visitorId) {
return NextResponse.json({ error: 'Missing visitor ID' }, { status: 400 });
}
const body = await request.json();
const parsed = presenceSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
const serverTs = await updatePresence(visitorId, parsed.data);
return NextResponse.json({ serverTs });
}
7.2 Events API
File: src/app/api/events/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { logEvents } from '@/server/visitor/event-store';
const eventSchema = z.object({
type: z.enum(['page_view', 'nav', 'click', 'form_submit', 'chat_message', 'download']),
route: z.string().max(500).optional(),
referrer: z.string().max(1000).optional(),
payload: z.record(z.unknown()).optional(),
ts: z.number(),
});
const eventsBodySchema = z.object({
events: z.array(eventSchema).max(20),
});
export async function POST(request: NextRequest) {
const visitorId = request.headers.get('x-visitor-id');
if (!visitorId) {
return NextResponse.json({ error: 'Missing visitor ID' }, { status: 400 });
}
const body = await request.json();
const parsed = eventsBodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
await logEvents(visitorId, parsed.data.events);
return NextResponse.json({ ok: true });
}
7.3 Admin Online API
File: src/app/api/admin/online/route.ts
import { NextResponse } from 'next/server';
import { getOnlineVisitors } from '@/server/visitor/presence-store';
export async function GET() {
const visitors = await getOnlineVisitors();
return NextResponse.json({ visitors, count: visitors.length });
}
8. Types
File: src/types/visitor.ts
export type EventType =
| 'page_view'
| 'nav'
| 'click'
| 'form_submit'
| 'chat_message'
| 'download';
export interface TrackEvent {
type: EventType;
route?: string;
referrer?: string;
payload?: Record<string, unknown>;
ts?: number;
}
export interface OnlineVisitor {
visitorId: string;
route: string;
title: string;
lastSeen: number;
uaHash: string;
geo?: string;
}
export interface TrackedEvent {
visitorId: string;
ts: number;
type: EventType;
route: string;
referrer?: string;
payload?: Record<string, unknown>;
}
9. Environment Variables (Client-Side)
Add to .env.template:
# Visitor Tracking (public - exposed to client)
NEXT_PUBLIC_PRESENCE_ENABLED=false
NEXT_PUBLIC_EVENTS_ENABLED=false
NEXT_PUBLIC_PRESENCE_HEARTBEAT_SECONDS=60
10. File Structure Summary
src/
├── app/
│ ├── api/
│ │ ├── presence/route.ts
│ │ ├── events/route.ts
│ │ └── admin/
│ │ └── online/route.ts
│ └── admin/
│ └── visitors/
│ └── page.tsx
├── components/
│ ├── admin/
│ │ ├── OnlineVisitorsPanel.tsx
│ │ ├── RecentEventsTable.tsx
│ │ └── EventTypeBadge.tsx
│ └── tracking/
│ ├── PresenceHeartbeat.tsx
│ ├── PageViewTracker.tsx
│ └── TrackedDownloadLink.tsx
├── context/
│ └── VisitorProvider.tsx
├── hooks/
│ ├── usePresenceHeartbeat.ts
│ ├── useEventTracker.ts
│ └── usePageViewTracker.ts
├── lib/
│ └── visitor/
│ └── getVisitorId.ts
├── server/
│ └── visitor/
│ ├── presence-store.ts
│ └── event-store.ts
└── types/
└── visitor.ts
11. Implementation Order
- Types & utilities –
src/types/visitor.ts,src/lib/visitor/getVisitorId.ts - Middleware update – Add visitorId cookie handling to
middleware.ts - Core hooks –
usePresenceHeartbeat,useEventTracker,usePageViewTracker - Context provider –
VisitorProviderwith conditional rendering - Tracking components – Headless
PresenceHeartbeat,PageViewTracker - API routes –
/api/presence,/api/events(depends on server stores from infra plan) - Layout integration – Wire up provider in
layout.tsx - Admin API –
/api/admin/online - Admin UI – Visitors page with
OnlineVisitorsPanel,RecentEventsTable - Instrumentation – Add tracking to chat, downloads, key CTAs
12. Testing Considerations
- Unit tests: Hook logic (batching, debounce, cleanup)
- Integration tests: API routes with mock DynamoDB
- E2E tests:
- Verify heartbeat updates
lastSeenin presence table - Verify
/api/admin/onlinereturns expected visitor count - Verify events are logged on page navigation
- Verify heartbeat updates
13. Privacy & Performance Notes
- No PII stored: visitorId is hashed userId or anonymous UUID
- Payload size capped at API level (prevent abuse)
- Heartbeat uses
sendBeaconfor unload (non-blocking) - Event batching reduces API calls (max 20 events per request)
- All tracking disabled by default (env flags)
- Admin-only access to visitor data (existing auth middleware)
