Jakub Mazur

Web Developer

Maastricht Netherlands

Back

Adding Views and Likes to Blog Posts in Next.js Without Breaking Static Generation

August 7, 2025 (1 day ago)

0

I love static sites. They're fast, SEO-friendly, and perfect for blogs. But then I wanted to add view counts and like buttons to my posts, and suddenly things got complicated.

The Problem

Adding likes and views to a static blog creates this weird tension. Static sites are pre-rendered at build time and cached globally, which makes them lightning fast. But engagement features are dynamic, user-specific, and need real-time updates.

The naive approach? Make everything dynamic. But there's a better way.

Why Not Suspense?

Suspense looks tempting because it seems to give you the best of both worlds:

// This seems like a good idea...
<Suspense fallback={<div>Loading views...</div>}>
  <ViewCount slug={post.slug} />
</Suspense>

At first glance, this looks perfect. The page loads fast, shows a loading state, then hydrates the engagement data. It feels like you're getting instant page loads with progressive enhancement.

The problem is that Suspense breaks static generation. When Next.js sees Suspense boundaries, it assumes the page needs server-side rendering because it has dynamic content. This means your pages can't be pre-rendered at build time anymore.

Instead of having pre-built pages that can be prefetched and cached in your browser, every page navigation now needs to hit a server. Even if that server is fast, it's still slower than having the page already loaded in your browser. Plus, you lose the amazing prefetching benefits that make static sites so good.

The Magic of Static Sites + Prefetching

Here's what makes static sites feel so fast: Next.js automatically prefetches all the links on your page when they come into view. So when someone's reading your blog post and they see a link to another post in your navigation or footer, Next.js quietly downloads that page in the background.

When they actually click the link, the page appears instantly because it's already loaded. This is what makes well-built Next.js sites feel like native mobile apps, instant navigation between pages.

If you break static generation with Suspense, you lose this prefetching magic. Now every page navigation needs to hit a server, which kills that snappy feeling.

The Better Approach

Instead, I render a static shell and fetch engagement data on the client side:

// This keeps your page static
export default function BlogPost({ post }: { post: BlogPost }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div className="flex gap-4">
        <EngagementStats slug={post.slug} />
      </div>
      <div className="markdown">{post.content}</div>
    </article>
  );
}

The EngagementStats component fetches both views and likes after the page loads. This way, the page itself is completely static and gets all the prefetching benefits, but the engagement features are still dynamic.

Choosing the Right Database

For storing likes and views, I use Redis (specifically Upstash Redis). You might wonder why not just use a regular database like PostgreSQL.

Redis is perfect for this type of simple data because it's an in-memory database. That means it stores everything in RAM instead of on disk, which makes it incredibly fast. When someone likes a post, I can increment the counter and check if they've already liked it in just a few milliseconds.

For engagement features where you're doing lots of simple reads and writes (getting view counts, incrementing likes, checking if someone already liked something), Redis is way overkill in the best way. It's like using a sports car for grocery shopping, but hey, groceries get done really fast.

Building the View Counter

First, I created API routes for views and likes:

// app/api/views/[slug]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getViewCount, recordViewCount } from "@/lib/actions/viewCount";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ slug: string }> },
) {
  try {
    const { slug } = await params;

    // Get current views and record this view in parallel
    const [currentViews] = await Promise.all([
      getViewCount(slug),
      recordViewCount(slug), // Auto-record the view on GET
    ]);

    return NextResponse.json({
      views: currentViews.views + 1, // Return incremented count
    });
  } catch (error) {
    console.error("Error fetching/recording views:", error);
    return NextResponse.json(
      { error: "Failed to fetch views" },
      { status: 500 },
    );
  }
}

Then the client component that handles both views and likes:

// components/blog/engagement-stats.tsx
"use client";

import useSWR from "swr";
import { ShowViews } from "./show-views";
import { LikeButton } from "./like-button";

interface EngagementStatsProps {
  slug: string;
}

export function EngagementStats({ slug }: EngagementStatsProps) {
  const {
    data: likes,
    error: likesError,
    isLoading: likesLoading,
  } = useSWR(`/api/likes/${slug}`);
  const {
    data: views,
    error: viewsError,
    isLoading: viewsLoading,
  } = useSWR(`/api/views/${slug}`);

  const isLoading = likesLoading || viewsLoading;

  return (
    <div
      className={`flex items-center gap-2 transition-all duration-500 ${
        isLoading ? "translate-y-1 opacity-0" : "translate-y-0 opacity-100"
      }`}
    >
      <ShowViews views={views?.views ?? 0} hasError={!!viewsError} />
      <LikeButton
        slug={slug}
        count={likes?.count ?? 0}
        liked={likes?.hasLiked ?? false}
        isLoading={likesLoading}
        hasError={!!likesError}
      />
    </div>
  );
}

Building the Like Button

For likes, I wanted to prevent people from spamming the button, so I added IP hashing and server actions:

// lib/actions/likeCount.ts
"use server";

import redis from "../redis";
import { getHashedIP } from "./hashIP";

export async function getLikeCount(slug: string) {
  const hashedIp = await getHashedIP();
  const likeKey = ["likes", "blogs", slug].join(":");
  const userLikeKey = ["ip", hashedIp, "liked", slug].join(":");

  const [likeCount, hasLiked] = await Promise.all([
    redis.get<number>(likeKey),
    redis.get(userLikeKey),
  ]);

  return {
    count: likeCount ?? 0,
    hasLiked: !!hasLiked,
  };
}

export async function toggleLike(slug: string) {
  const hashedIp = await getHashedIP();
  const likeKey = ["likes", "blogs", slug].join(":");
  const userLikeKey = ["ip", hashedIp, "liked", slug].join(":");

  const hasLiked = await redis.get(userLikeKey);
  const pipeline = redis.pipeline();

  if (hasLiked) {
    // Unlike
    pipeline.decr(likeKey);
    pipeline.del(userLikeKey);
  } else {
    // Like
    pipeline.incr(likeKey);
    pipeline.set(userLikeKey, "1");
  }

  await pipeline.exec();
  const newCount = Math.max(0, (await redis.get<number>(likeKey)) ?? 0);

  return {
    success: true,
    newCount,
    liked: !hasLiked,
  };
}

The like system also needs API endpoints to handle both fetching current like state and toggling likes. Here's the API route that connects to our server actions:

// app/api/likes/[slug]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getLikeCount, toggleLike } from "@/lib/actions/likeCount";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ slug: string }> },
) {
  try {
    const { slug } = await params;
    const result = await getLikeCount(slug);

    return NextResponse.json({
      count: result.count,
      hasLiked: result.hasLiked,
    });
  } catch (error) {
    console.error("Error fetching likes:", error);
    return NextResponse.json(
      { error: "Failed to fetch likes" },
      { status: 500 },
    );
  }
}

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ slug: string }> },
) {
  try {
    const { slug } = await params;
    const result = await toggleLike(slug);

    if (result.success) {
      return NextResponse.json({
        count: result.newCount,
        hasLiked: result.liked,
      });
    } else {
      return NextResponse.json(
        { error: "Failed to toggle like" },
        { status: 500 },
      );
    }
  } catch (error) {
    console.error("Error toggling like:", error);
    return NextResponse.json(
      { error: "Failed to toggle like" },
      { status: 500 },
    );
  }
}

And the like button component:

// components/blog/like-button.tsx
"use client";

import { mutate } from "swr";
import { useTransition } from "react";
import { Heart } from "lucide-react";

interface LikeButtonProps {
  slug: string;
  count: number;
  liked: boolean;
  size?: "sm" | "md" | "lg";
  isLoading?: boolean;
  hasError?: boolean;
}

export function LikeButton({
  slug,
  count,
  liked,
  size = "md",
  isLoading = false,
  hasError = false,
}: LikeButtonProps) {
  const [isPending, startTransition] = useTransition();

  const sizeConfig = {
    sm: { icon: 14, text: "text-xs", gap: "gap-1" },
    md: { icon: 16, text: "text-sm", gap: "gap-1" },
    lg: { icon: 20, text: "text-base", gap: "gap-2" },
  };

  const config = sizeConfig[size];

  const handleLike = () => {
    const newLiked = !liked;
    const newCount = liked ? count - 1 : count + 1;

    // Optimistic update
    mutate(
      `/api/likes/${slug}`,
      { count: newCount, hasLiked: newLiked },
      false,
    );

    startTransition(async () => {
      try {
        const response = await fetch(`/api/likes/${slug}`, {
          method: "POST",
        });

        if (response.ok) {
          const result = await response.json();
          mutate(`/api/likes/${slug}`, result, false);
        } else {
          // Revert on error
          mutate(`/api/likes/${slug}`, { count, hasLiked: liked }, false);
        }
      } catch (error) {
        console.error("Failed to toggle like:", error);
        mutate(`/api/likes/${slug}`, { count, hasLiked: liked }, false);
      }
    });
  };

  return (
    <button
      onClick={handleLike}
      disabled={isPending || isLoading}
      className={`group flex items-center ${config.gap} transition-colors disabled:opacity-50`}
    >
      <Heart
        size={config.icon}
        className={`transition-colors ${
          liked
            ? "fill-black stroke-black"
            : "stroke-neutral-600 group-hover:stroke-black"
        }`}
      />
      <span
        className={`${config.text} transition-colors ${
          liked ? "text-black" : "text-neutral-600 group-hover:text-black"
        }`}
      >
        {hasError ? "0" : count}
      </span>
    </button>
  );
}

Why This Works So Well

This approach keeps pages static while adding interactivity. The initial page load is instant because it's served from a CDN, and the engagement data loads in the background. Pages work without JavaScript (progressive enhancement), and there are no hydration mismatches.

Most importantly, you keep all the prefetching magic that makes static sites feel so snappy.

Making It Even Better with SWR

For an even smoother experience, I use SWR throughout the engagement system. SWR (stale-while-revalidate) is a data fetching library that handles caching, revalidation, and optimistic updates.

The basic idea is that SWR shows you cached data immediately (stale), then fetches fresh data in the background (revalidate). It also gives you tools for optimistic updates, where you update the UI immediately and then sync with the server.

My EngagementStats component uses SWR to fetch both views and likes data:

const {
  data: likes,
  error: likesError,
  isLoading: likesLoading,
} = useSWR(`/api/likes/${slug}`);
const {
  data: views,
  error: viewsError,
  isLoading: viewsLoading,
} = useSWR(`/api/views/${slug}`);

With SWR, clicking like/unlike feels instant because the UI updates immediately with mutate(), even before the server responds. If the server request fails, I manually revert the optimistic update to keep the UI in sync.

Wrapping Up

You don't have to sacrifice static generation to add dynamic features. Keep your shell static, load engagement data client-side, and make sure everything works without JavaScript first. Your users get fast page loads, instant navigation between pages, and engaging interactions without any compromises.

Enjoyed this post? Show some love!

© 2025 - Jakub Mazur