javascript
43 lines · 8 steps
A deduped page-view counter in Next.js
An edge route that counts unique page views by using a short-lived Redis key to dedupe repeat visitors.
Explained by
highlit
1import { NextResponse } from 'next/server';
2import { Redis } from '@upstash/redis';
3
4export const runtime = 'edge';
5
6const redis = Redis.fromEnv();
7
8export async function POST(request) {
9 const { slug } = await request.json();
10
11 if (typeof slug !== 'string' || !/^[a-z0-9\-\/]+$/.test(slug)) {
12 return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
13 }
14
15 const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '0.0.0.0';
16 const dedupeKey = `dedupe:${slug}:${ip}`;
17
18 const isNew = await redis.set(dedupeKey, '1', { nx: true, ex: 60 * 60 * 24 });
19
20 const views = isNew
21 ? await redis.incr(`views:${slug}`)
22 : await redis.get(`views:${slug}`) ?? 0;
23
24 return NextResponse.json(
25 { slug, views: Number(views), counted: Boolean(isNew) },
26 { headers: { 'Cache-Control': 'no-store' } }
27 );
28}
29
30export async function GET(request) {
31 const slug = new URL(request.url).searchParams.get('slug');
32
33 if (!slug) {
34 return NextResponse.json({ error: 'Missing slug' }, { status: 400 });
35 }
36
37 const views = await redis.get(`views:${slug}`);
38
39 return NextResponse.json(
40 { slug, views: Number(views ?? 0) },
41 { headers: { 'Cache-Control': 's-maxage=30, stale-while-revalidate=60' } }
42 );
43}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A Redis SET with NX and an expiry is an atomic way to check-and-claim a key, perfect for time-windowed deduplication.
- 2Validating and constraining user input before it becomes a database key prevents injection and key pollution.
- 3Cache-Control headers should match intent: no-store for writes, stale-while-revalidate for cheap public reads.
Related explainers
javascript
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
go
package logging import ( "context"
Deduplicating log attributes in Go's slog
decorator-pattern
structured-logging
immutability
Intermediate
8 steps
python
import secrets from django.contrib.auth import authenticate, login from django.core.cache import cache
Two-factor login with OTP in Django
two-factor-auth
one-time-passwords
caching
Intermediate
9 steps
javascript
import { useEffect, useRef, useState } from 'react'; export function useDelayedFlag(active, delay = 300) { const [visible, setVisible] = useState(false);
Delaying a loading spinner with a React hook
custom-hooks
debouncing
cleanup
Intermediate
8 steps
go
package middleware import ( "net/http"
Per-plan export limits in Gin middleware
middleware
rate-limiting
authorization
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/a-deduped-page-view-counter-in-next-js-explained-javascript-f164/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.