How to build a countdown timer in Next.js

A complete, working countdown for the App Router: a Server Component that renders the correct remaining time in the first byte of HTML, a small client component that ticks it without drifting, and a webhook route that fires when the deadline passes. Every snippet below runs as written. The last section covers the four ways the usual tutorial version breaks in production — read that one even if you build the rest yourself.

The version that looks fine

This is what most countdown tutorials produce, and for a marketing page counting to a fixed public date it is perfectly adequate.

app/components/countdown.tsx

"use client";
import { useEffect, useState } from "react";

// The version most tutorials show. It works, and it is wrong
// in four specific ways — see the section below.
export function Countdown({ deadline }: { deadline: string }) {
  const [left, setLeft] = useState(0);

  useEffect(() => {
    const end = new Date(deadline).getTime();
    const id = setInterval(() => {
      setLeft(Math.max(0, Math.round((end - Date.now()) / 1000)));
    }, 1000);
    return () => clearInterval(id);
  }, [deadline]);

  return <span>{left}s</span>;
}

It breaks in four specific ways, and all four are invisible in development:

  1. It trusts the visitor's clock. Date.now() is whatever the device says. Wrong by minutes on an unsynced machine, and deliberately wrong on anything with a discount attached.
  2. It renders zero first. useState(0) plus a one-second interval means a flash of “0s” before the first tick — a layout shift on every load.
  3. It stalls in background tabs. Browsers throttle hidden-tab timers to one second, then to roughly one minute. Come back and the number jumps.
  4. Nothing happens at zero. The display reaches 0 but your backend has no idea. Whatever the deadline was supposed to trigger does not.
Points one and four are the substantive ones. A countdown is presentation; the deadline is a fact. If those two live in different places they will disagree, usually in front of a customer.

A small API client

One module that knows the base URL, the auth header, and the response envelope. Everything else calls this.

lib/countdown.ts

// lib/countdown.ts — one place that knows the envelope.
const BASE = "https://countdownshare.com/api/v1";

export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(BASE + path, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.COUNTDOWNSHARE_API_KEY}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
    cache: "no-store",
  });

  const payload = await response.json();
  if (!response.ok) {
    throw new Error(
      `${payload.error.code}: ${payload.error.message} (${payload.request_id})`,
    );
  }
  return payload.data as T;
}
cache: "no-store" is not optional. The App Router caches fetch by default, and a cached status response is a countdown frozen at whenever the cache was filled — on a static route, possibly build time. This is the single most common cause of “my countdown is wrong after deploying”.

Render the first frame on the server

A Server Component fetches the authoritative status, so the HTML that arrives already contains the right number. No spinner, no layout shift, and the API key never crosses the network boundary.

app/sale/page.tsx

// app/sale/page.tsx — a Server Component.
//
// The countdown is correct in the first byte of HTML. No loading
// spinner, no layout shift, and the key never reaches the browser.
import { api } from "@/lib/countdown";
import { CountdownClock } from "./countdown-clock";

export default async function SalePage() {
  const status = await api<{
    ended: boolean;
    server_time: string;
    remaining: { total_seconds: number };
  }>(`/timers/${process.env.SALE_TIMER_ID}/status`);

  if (status.ended) return <SaleClosed />;

  return (
    <CountdownClock
      initialSeconds={status.remaining.total_seconds}
      serverTime={status.server_time}
    />
  );
}

Tick on the client, against the server's clock

The client component animates between reads. The trick is measuring the difference between the visitor's clock and ours once, then counting against a corrected baseline — which makes a wrong system clock irrelevant.

app/sale/countdown-clock.tsx

"use client";
import { useEffect, useRef, useState } from "react";

/**
 * Ticks locally, but counts against the SERVER's clock.
 *
 * serverTime is measured once against Date.now() to get the skew,
 * then every tick subtracts it. A visitor whose clock is an hour
 * fast still sees the correct remaining time.
 */
export function CountdownClock({
  initialSeconds,
  serverTime,
}: {
  initialSeconds: number;
  serverTime: string;
}) {
  const [left, setLeft] = useState(initialSeconds);
  const skew = useRef(Date.now() - new Date(serverTime).getTime());
  const endsAt = useRef(Date.now() - skew.current + initialSeconds * 1000);

  useEffect(() => {
    function tick() {
      const now = Date.now() - skew.current;
      setLeft(Math.max(0, Math.round((endsAt.current - now) / 1000)));
    }

    const id = setInterval(tick, 1000);

    // A tab restored after hours has a stale value: recompute
    // immediately rather than waiting for the next interval.
    document.addEventListener("visibilitychange", tick);

    return () => {
      clearInterval(id);
      document.removeEventListener("visibilitychange", tick);
    };
  }, []);

  const h = Math.floor(left / 3600);
  const m = Math.floor((left % 3600) / 60);
  const s = left % 60;

  return (
    <time dateTime={`PT${left}S`} aria-live="polite">
      {h}h {String(m).padStart(2, "0")}m {String(s).padStart(2, "0")}s
    </time>
  );
}

What each part is doing

skew is the offset between the two clocks, captured on mount from server_time. endsAt is the deadline expressed in corrected local time, so every tick is a simple subtraction. The visibilitychange listener recomputes immediately when a throttled tab comes back, instead of showing a stale number until the next interval fires.

aria-live="polite" and a <time> element are worth keeping. A countdown that updates every second with aria-live="assertive" makes a screen reader unusable; polite announces without interrupting.

Polling from the browser, without exposing the key

Only needed when you are building a custom UI that re-reads timer state — a duration timer someone else can pause, for instance. Your route handler holds the key; the browser calls your route.

app/api/countdown/[id]/route.ts

// app/api/countdown/[id]/route.ts
//
// Needed only for timers you poll from the browser. Published
// timers already have public output URLs that need no key.
import { api } from "@/lib/countdown";

export async function GET(
  _request: Request,
  { params }: { params: { id: string } },
) {
  const status = await api<{
    ended: boolean;
    server_time: string;
    remaining: { total_seconds: number };
  }>(`/timers/${params.id}/status`);

  // Return only what the page needs — do not proxy the whole
  // payload blindly.
  return Response.json({
    remaining_seconds: status.remaining.total_seconds,
    server_time: status.server_time,
    ended: status.ended,
  });
}

For a plain display you can usually skip this entirely. Published timers expose a hosted page, an iframe embed, and an email GIF as public URLs — embeds and hosted pages.

Do something when it ends

This is the piece a pure front-end countdown cannot have. Register a destination once, attach a rule to the timer, and Next.js receives a signed POST at the deadline.

app/api/webhooks/countdownshare/route.ts

// app/api/webhooks/countdownshare/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  // request.text() gives the RAW body. Route handlers do not
  // pre-parse it, which is exactly what the signature needs.
  const raw = await request.text();

  const timestamp = request.headers.get("x-countdownshare-timestamp");
  const signature = request.headers.get("x-countdownshare-signature");
  if (!timestamp || !signature) return new Response("Unsigned", { status: 401 });

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return new Response("Stale", { status: 401 });
  }

  const expected = createHmac("sha256", process.env.COUNTDOWNSHARE_WEBHOOK_SECRET!)
    .update(`${timestamp}.${raw}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature.replace(/^v1=/, ""), "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(raw);   // parse only after verifying

  // Acknowledge inside 10 seconds; do the work after.
  await enqueue("countdown-event", event);
  return new Response(null, { status: 200 });
}
await request.text() is load-bearing. The signature covers the raw bytes, so calling request.json() first destroys the thing you need to verify against — and re-serialising the parsed object produces different whitespace and key order. Full detail on verify a delivery.

Revalidating the page at the deadline

A nice Next.js-specific pattern: once the timer ends, the page no longer needs to be dynamic. Revalidate from inside the webhook handler and it can go back to being static and still be correct.

Inside the verified handler

// Inside the webhook handler, once verified:
import { revalidatePath, revalidateTag } from "next/cache";

if (event.data.status === "ended") {
  // The sale page can now be static again — and correct.
  revalidatePath("/sale");
  revalidateTag(`timer:${event.timer.id}`);
}

Common questions

Why does my Next.js countdown show the wrong time after deploying?

Almost always caching. A fetch without cache: "no-store" is cached by default in the App Router, so a statically rendered page can serve remaining time captured at build. Set cache: "no-store" on the status call, or export const dynamic = "force-dynamic" on the route.

Should the countdown be a Server Component or a Client Component?

Both, split by job. A Server Component fetches the authoritative remaining time so the first paint is correct with no spinner; a small Client Component ticks it. Keep the fetch on the server — it needs the API key, which must never reach the browser.

Do I need a proxy route to show a countdown?

Not usually. A published timer returns public output URLs — a hosted page, an iframe embed, and an email GIF — that need no key at all. The proxy is only for building a custom UI that polls timer state from the browser.

Why does my webhook signature verification fail in Next.js?

Almost certainly because the body was parsed before verification. Use await request.text() and verify against that string. If you call request.json() first, the raw bytes are gone and re-serialising produces different whitespace and key order, so the HMAC will never match.

Next steps

Build it against a real API

Sandbox is free with any account and needs no plan. Every snippet on this page runs unchanged once COUNTDOWNSHARE_API_KEY is set.