Building a Modern View Counter with Next.js and Neon DB (2025)
Learn how to create a high-performance, real-time view counter using Next.js 15, Neon DB, and modern caching strategies. Perfect for blogs and portfolios.

Building a Modern View Counter with Next.js and Neon DB
This guide has been updated for 2025, incorporating the latest features and best practices in web development.
A view counter is a great way to show engagement on your blog posts or portfolio projects. Let's build a modern, scalable solution that's both performant and cost-effective.
Architecture Overview
We'll create a view counter that:
- Uses Neon DB's serverless PostgreSQL
- Implements efficient caching
- Handles concurrent updates
- Prevents spam and abuse
- Provides real-time updates
Database Setup
First, create a table in your Neon DB:
sql
CREATE TABLE page_views (
slug VARCHAR(255) PRIMARY KEY,
views BIGINT DEFAULT 0,
last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
unique_views BIGINT DEFAULT 0,
bot_filtered_views BIGINT DEFAULT 0
);
-- Index for faster queries
CREATE INDEX idx_page_views_slug ON page_views(slug);
-- Function for atomic updates
CREATE OR REPLACE FUNCTION increment_views(
page_slug VARCHAR,
is_unique BOOLEAN DEFAULT false,
is_bot BOOLEAN DEFAULT false
) RETURNS BIGINT AS $$
BEGIN
INSERT INTO page_views (slug, views, unique_views, bot_filtered_views)
VALUES (page_slug, 1, CASE WHEN is_unique THEN 1 ELSE 0 END, CASE WHEN NOT is_bot THEN 1 ELSE 0 END)
ON CONFLICT (slug) DO UPDATE SET
views = page_views.views + 1,
unique_views = page_views.unique_views + CASE WHEN is_unique THEN 1 ELSE 0 END,
bot_filtered_views = page_views.bot_filtered_views + CASE WHEN NOT is_bot THEN 1 ELSE 0 END,
last_updated = CURRENT_TIMESTAMP
RETURNING views;
END;
$$ LANGUAGE plpgsql;
API Implementation
Create a type-safe database client:
typescript
// lib/db.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
export interface PageView {
slug: string;
views: number;
uniqueViews: number;
botFilteredViews: number;
lastUpdated: Date;
}
export async function incrementViews(
slug: string,
isUnique: boolean = false,
isBot: boolean = false,
): Promise<number> {
const [result] = await db.execute<{ views: number }>`
SELECT increment_views(${slug}, ${isUnique}, ${isBot}) as views
`;
return result.views;
}
Create a server action for view tracking:
typescript
// app/actions/track-view.ts
"use server";
import { cookies } from "next/headers";
import { kv } from "@vercel/kv";
import { incrementViews } from "@/lib/db";
import { isBot } from "@/lib/user-agent";
// app/actions/track-view.ts
// app/actions/track-view.ts
// app/actions/track-view.ts
// app/actions/track-view.ts
// app/actions/track-view.ts
// app/actions/track-view.ts
export async function trackPageView(slug: string) {
try {
// Rate limiting
const ip = headers().get("x-forwarded-for") ?? "unknown";
const rateLimitKey = `rate-limit:${slug}:${ip}`;
const rateLimitTTL = 60; // 1 minute
const rateLimited = await kv.get(rateLimitKey);
if (rateLimited) {
return { error: "Rate limited" };
}
await kv.set(rateLimitKey, true, { ex: rateLimitTTL });
// Check for unique views
const store = cookies();
const viewedKey = `viewed:${slug}`;
const hasViewed = store.get(viewedKey);
const isUnique = !hasViewed;
if (isUnique) {
store.set(viewedKey, "1", {
maxAge: 60 * 60 * 24 * 365, // 1 year
path: "/",
});
}
// Check if request is from a bot
const userAgent = headers().get("user-agent") ?? "";
const botRequest = isBot(userAgent);
// Increment views
const views = await incrementViews(slug, isUnique, botRequest);
// Cache the result
await kv.set(`views:${slug}`, views, { ex: 60 }); // Cache for 1 minute
return { views, isUnique, botRequest };
} catch (error) {
console.error("Failed to track view:", error);
return { error: "Failed to track view" };
}
}
Client Component
Create a real-time view counter component:
typescript
// components/view-counter.tsx
'use client';
import { useEffect, useState } from 'react';
import { usePostHog } from 'posthog-js/react';
import { trackPageView } from '@/app/actions/track-view';
import { useCountUp } from '@/hooks/use-count-up';
interface ViewCounterProps {
slug: string;
initialViews?: number;
className?: string;
}
export function ViewCounter({
slug,
initialViews = 0,
className
}: ViewCounterProps) {
const [views, setViews] = useState(initialViews);
const posthog = usePostHog();
const countUpRef = useCountUp(views);
useEffect(() => {
const trackView = async () => {
// Track with PostHog
posthog?.capture('page_view', { slug });
const result = await trackPageView(slug);
if (!('error' in result)) {
setViews(result.views);
// Track unique views in PostHog
if (result.isUnique) {
posthog?.capture('unique_view', { slug });
}
}
};
trackView();
}, [slug, posthog]);
return (
<span ref={countUpRef} className={className}>
{views.toLocaleString()} views
</span>
);
}
Usage in Blog Posts
Add the counter to your blog posts:
typescript
// app/blog/[slug]/page.tsx
import { ViewCounter } from '@/components/view-counter';
import { getInitialViews } from '@/lib/db';
interface BlogPostProps {
params: { slug: string };
}
export default async function BlogPost({ params }: BlogPostProps) {
const initialViews = await getInitialViews(params.slug);
return (
<article>
<h1>Blog Post Title</h1>
<ViewCounter
slug={params.slug}
initialViews={initialViews}
className="text-sm text-gray-500"
/>
{/* Rest of blog post content */}
</article>
);
}
Performance Optimizations
1. Caching Strategy
Implement a multi-layer caching strategy:
typescript
// lib/cache.ts
import { unstable_cache } from "next/cache";
import { kv } from "@vercel/kv";
export const getViewCount = unstable_cache(
async (slug: string) => {
// Try KV cache first
const cached = await kv.get<number>(`views:${slug}`);
if (cached) return cached;
// Fall back to database
const views = await db.query.pageViews.findFirst({
where: eq(pageViews.slug, slug),
});
// Update KV cache
if (views) {
await kv.set(`views:${slug}`, views.views, { ex: 60 });
}
return views?.views ?? 0;
},
["views"],
{ revalidate: 60 }, // Revalidate every minute
);
2. Edge Runtime
Deploy the view counter API to the edge:
typescript
// app/api/views/route.ts
export const runtime = "edge";
export async function POST(request: Request) {
const { slug } = await request.json();
try {
const result = await trackPageView(slug);
return Response.json(result);
} catch (error) {
return Response.json({ error: "Failed to track view" }, { status: 500 });
}
}
3. Batch Updates
Implement batch updates for better performance:
typescript
// lib/batch.ts
import { db } from "./db";
const BATCH_SIZE = 100;
const FLUSH_INTERVAL = 1000 * 60; // 1 minute
class ViewBatcher {
private batch: Map<string, number> = new Map();
private timer: NodeJS.Timeout | null = null;
constructor() {
this.startTimer();
}
private startTimer() {
this.timer = setInterval(() => {
this.flush();
}, FLUSH_INTERVAL);
}
async add(slug: string) {
this.batch.set(slug, (this.batch.get(slug) ?? 0) + 1);
if (this.batch.size >= BATCH_SIZE) {
await this.flush();
}
}
private async flush() {
if (this.batch.size === 0) return;
const updates = Array.from(this.batch.entries());
this.batch.clear();
try {
await db.transaction(async tx => {
for (const [slug, count] of updates) {
await tx.execute(
`UPDATE page_views
SET views = views + $1
WHERE slug = $2`,
[count, slug],
);
}
});
} catch (error) {
console.error("Failed to flush view batch:", error);
// Re-add failed updates to batch
for (const [slug, count] of updates) {
this.batch.set(slug, (this.batch.get(slug) ?? 0) + count);
}
}
}
}
export const viewBatcher = new ViewBatcher();
Analytics Integration
1. PostHog Integration
Track detailed analytics:
typescript
// lib/analytics.ts
import posthog from "posthog-js";
export function trackView(slug: string, metadata: ViewMetadata) {
posthog.capture("page_view", {
slug,
referrer: document.referrer,
session_id: getSessionId(),
...metadata,
});
}
export function trackEngagement(slug: string, duration: number) {
posthog.capture("view_duration", {
slug,
duration_seconds: duration,
is_engaged: duration > 30, // 30 seconds threshold
});
}
2. Dashboard Component
Create a view analytics dashboard:
typescript
// components/view-analytics.tsx
import { BarChart, LineChart } from '@/components/charts';
import { getViewAnalytics } from '@/lib/analytics';
export async function ViewAnalytics({ slug }: { slug: string }) {
const analytics = await getViewAnalytics(slug);
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border p-4">
<h3>Views Over Time</h3>
<LineChart data={analytics.timeSeriesData} />
</div>
<div className="rounded-lg border p-4">
<h3>Traffic Sources</h3>
<BarChart data={analytics.referrerData} />
</div>
</div>
);
}
Security Considerations
1. Rate Limiting
Implement proper rate limiting:
typescript
// lib/rate-limit.ts
import { headers } from "next/headers";
import { kv } from "@vercel/kv";
export async function rateLimit(key: string, limit: number = 10, window: number = 60) {
const ip = headers().get("x-forwarded-for") ?? "unknown";
const identifier = `rate-limit:${key}:${ip}`;
const [current] = await kv.multi().incr(identifier).expire(identifier, window).exec();
if (current > limit) {
throw new Error("Rate limit exceeded");
}
return current;
}
2. Bot Detection
Implement sophisticated bot detection:
typescript
// lib/bot-detection.ts
export function isBot(userAgent: string): boolean {
// Common bot patterns
const botPatterns = [/bot/i, /crawler/i, /spider/i, /googlebot/i, /bingbot/i, /yahoo/i];
// Check user agent
if (botPatterns.some(pattern => pattern.test(userAgent))) {
return true;
}
// Check for headless browsers
const headlessPatterns = [/headless/i, /puppet/i, /selenium/i];
if (headlessPatterns.some(pattern => pattern.test(userAgent))) {
return true;
}
return false;
}
Testing
1. Unit Tests
typescript
// __tests__/view-counter.test.ts
import { describe, it, expect, vi } from 'vitest';
import { render, act } from '@testing-library/react';
import { ViewCounter } from '@/components/view-counter';
describe('ViewCounter', () => {
it('displays initial views correctly', () => {
const { getByText } = render(
<ViewCounter slug="test" initialViews={100} />
);
expect(getByText('100 views')).toBeInTheDocument();
});
it('increments views after mounting', async () => {
vi.mock('@/app/actions/track-view', () => ({
trackPageView: vi.fn().mockResolvedValue({ views: 101 })
}));
const { getByText } = render(
<ViewCounter slug="test" initialViews={100} />
);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(getByText('101 views')).toBeInTheDocument();
});
});
2. Integration Tests
typescript
// __tests__/integration/view-tracking.test.ts
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { trackPageView } from "@/app/actions/track-view";
import { cleanupTestDatabase, createTestDatabase } from "../helpers";
describe("View Tracking Integration", () => {
beforeAll(async () => {
await createTestDatabase();
});
afterAll(async () => {
await cleanupTestDatabase();
});
it("tracks views correctly", async () => {
const slug = "test-post";
// First view
const result1 = await trackPageView(slug);
expect(result1.views).toBe(1);
expect(result1.isUnique).toBe(true);
// Second view
const result2 = await trackPageView(slug);
expect(result2.views).toBe(2);
expect(result2.isUnique).toBe(false);
});
});
Monitoring
1. Error Tracking
typescript
// lib/monitoring.ts
import * as Sentry from "@sentry/nextjs";
export function trackViewError(error: Error, context: any) {
Sentry.captureException(error, {
tags: {
type: "view_counter",
slug: context.slug,
},
extra: context,
});
}
2. Performance Monitoring
typescript
// lib/performance.ts
export async function measureViewPerformance(slug: string, startTime: number) {
const duration = performance.now() - startTime;
// Report to monitoring service
await fetch("/api/metrics", {
method: "POST",
body: JSON.stringify({
metric: "view_counter_duration",
value: duration,
tags: { slug },
}),
});
}
Deployment
Deploy your view counter with proper scaling:
typescript
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
},
env: {
DATABASE_URL: process.env.DATABASE_URL,
KV_URL: process.env.KV_URL,
KV_REST_API_URL: process.env.KV_REST_API_URL,
KV_REST_API_TOKEN: process.env.KV_REST_API_TOKEN,
},
};
export default nextConfig;
Next Steps
To extend this view counter:
- Add real-time updates with WebSocket
- Implement A/B testing
- Add geographic tracking
- Create detailed analytics dashboard
- Optimize for Core Web Vitals
Resources
This implementation is optimized for high-traffic sites and can handle thousands of concurrent views while maintaining low latency.