Loading...

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 (2025)

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:

  1. Add real-time updates with WebSocket
  2. Implement A/B testing
  3. Add geographic tracking
  4. Create detailed analytics dashboard
  5. 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.