Guide

Changelog Widget for React, Next.js & Vue: Copy-Paste Guide (2026)

Add an in-app changelog widget to React, Next.js (App Router), or Vue 3 apps. Working code, SSR-safe patterns, dark mode, unread state — and what you don't have to build.

Photo of ReleaseGlow TeamReleaseGlow Team
April 22, 2026
11 min read

You want to surface a "What's new" popover in your React, Next.js, or Vue app. The button lives next to the user avatar, shows an unread dot when a new release drops, opens a small panel listing the last ten entries, and remembers what each user has seen.

This guide ships working code for all three frameworks, flags the SSR and hydration traps that usually break it, and lists — honestly — what a homegrown widget costs you six months in. We also cover how the ReleaseGlow in-app widget handles the same contract with one line of installation.

What "a changelog widget" actually needs to do

Before any code, here's the minimum contract:

  1. A trigger — a button, usually an icon, typically in the top-right of the app shell.
  2. An unread indicator — a dot, count, or ping that disappears once the user opens the panel.
  3. A panel — a popover or drawer listing recent entries with title, date, category, and body.
  4. Persistence — "last seen timestamp" stored per user so the dot logic works across devices.
  5. A data source — a JSON feed, RSS, or API your frontend can fetch. This is the piece people underestimate the most.

If you're missing any of these, you don't have a widget. You have a link to a page.

The data contract we'll use

For every example below, we assume your backend exposes something like:

GET /api/changelog
{
  "entries": [
    {
      "id": "ent_2026_04_18",
      "title": "Dark mode is live",
      "publishedAt": "2026-04-18T09:00:00Z",
      "category": "new",
      "excerpt": "Toggle dark mode from your profile menu.",
      "url": "/changelog/dark-mode"
    }
  ]
}

Category is one of new | improved | fixed | security. That's it. You can make this richer later.

React (plain, no framework)

A minimal implementation fits in 120 lines. Tailwind for styling, nothing fancy.

// ChangelogWidget.tsx
import { useEffect, useRef, useState } from "react";

type Entry = {
  id: string;
  title: string;
  publishedAt: string;
  category: "new" | "improved" | "fixed" | "security";
  excerpt: string;
  url: string;
};

const STORAGE_KEY = "changelog:lastSeen";

export function ChangelogWidget() {
  const [open, setOpen] = useState(false);
  const [entries, setEntries] = useState<Entry[]>([]);
  const [lastSeen, setLastSeen] = useState<string | null>(null);
  const panelRef = useRef<HTMLDivElement>(null);

  // Load entries + last-seen on mount
  useEffect(() => {
    fetch("/api/changelog")
      .then((r) => r.json())
      .then((d) => setEntries(d.entries));
    setLastSeen(localStorage.getItem(STORAGE_KEY));
  }, []);

  // Close on outside click
  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (!panelRef.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [open]);

  const unreadCount = entries.filter(
    (e) => !lastSeen || new Date(e.publishedAt) > new Date(lastSeen),
  ).length;

  const markRead = () => {
    const now = new Date().toISOString();
    localStorage.setItem(STORAGE_KEY, now);
    setLastSeen(now);
  };

  return (
    <div className="relative" ref={panelRef}>
      <button
        aria-label="What's new"
        onClick={() => {
          setOpen((v) => !v);
          if (!open) markRead();
        }}
        className="relative rounded-full p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
      >
        <SparkleIcon />
        {unreadCount > 0 && (
          <span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500" />
        )}
      </button>

      {open && (
        <div
          role="dialog"
          aria-label="Changelog"
          className="absolute right-0 mt-2 w-96 max-h-[32rem] overflow-y-auto rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-800 dark:bg-neutral-900"
        >
          <header className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
            <h3 className="font-semibold">What's new</h3>
          </header>
          <ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
            {entries.map((entry) => (
              <li key={entry.id} className="px-4 py-3">
                <a href={entry.url} className="block hover:bg-neutral-50 dark:hover:bg-neutral-800">
                  <CategoryBadge category={entry.category} />
                  <h4 className="mt-1 font-medium">{entry.title}</h4>
                  <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
                    {entry.excerpt}
                  </p>
                  <time className="mt-1 block text-xs text-neutral-500">
                    {new Date(entry.publishedAt).toLocaleDateString()}
                  </time>
                </a>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

function SparkleIcon() {
  return (
    <svg width="20" height="20" fill="none" viewBox="0 0 24 24">
      <path d="M12 2l2 6 6 2-6 2-2 6-2-6-6-2 6-2 2-6z" fill="currentColor" />
    </svg>
  );
}

function CategoryBadge({ category }: { category: Entry["category"] }) {
  const colors = {
    new: "bg-green-100 text-green-800",
    improved: "bg-blue-100 text-blue-800",
    fixed: "bg-yellow-100 text-yellow-800",
    security: "bg-red-100 text-red-800",
  };
  return (
    <span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${colors[category]}`}>
      {category}
    </span>
  );
}

This works. Drop it next to your avatar, point /api/changelog at your data, done.

And yet — you are one week away from the first bug. Keep reading.

Next.js 16 App Router

The App Router adds two wrinkles: Server Components don't have useState, and localStorage blows up on the server.

// components/ChangelogWidget.tsx
"use client";

// ... same implementation as the React example above ...

Mark the file "use client" at the top. Import it in a Server Component layout:

// app/(dashboard)/layout.tsx
import { ChangelogWidget } from "@/components/ChangelogWidget";

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <header className="flex items-center justify-between border-b px-6 py-3">
        <Logo />
        <div className="flex items-center gap-2">
          <ChangelogWidget />
          <UserMenu />
        </div>
      </header>
      {children}
    </div>
  );
}

Two things that will bite you:

  1. Hydration mismatch on the unread dot. The server renders the button with no dot (it can't read localStorage), the client renders it with a dot, React logs a warning. Fix: initialize lastSeen to null, compute unreadCount only after mount, and render the dot conditionally with suppressHydrationWarning or gate the entire dot behind a mounted boolean.

  2. fetch("/api/changelog") on a client component runs on every mount. Wrap it in SWR or TanStack Query with staleTime: 5 * 60 * 1000 so navigating between pages doesn't thrash your API.

Don't server-fetch the changelog in your root layout. It runs on every page navigation and couples your entire app's render time to your changelog API's latency. Fetch it on the client with a cache.

Vue 3 (Composition API)

<!-- ChangelogWidget.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";

type Entry = {
  id: string;
  title: string;
  publishedAt: string;
  category: "new" | "improved" | "fixed" | "security";
  excerpt: string;
  url: string;
};

const STORAGE_KEY = "changelog:lastSeen";

const open = ref(false);
const entries = ref<Entry[]>([]);
const lastSeen = ref<string | null>(null);
const panelRef = ref<HTMLElement | null>(null);

onMounted(async () => {
  const res = await fetch("/api/changelog");
  const data = await res.json();
  entries.value = data.entries;
  lastSeen.value = localStorage.getItem(STORAGE_KEY);
  document.addEventListener("mousedown", handleOutside);
});

onUnmounted(() => {
  document.removeEventListener("mousedown", handleOutside);
});

function handleOutside(e: MouseEvent) {
  if (open.value && !panelRef.value?.contains(e.target as Node)) {
    open.value = false;
  }
}

const unreadCount = computed(
  () =>
    entries.value.filter(
      (e) => !lastSeen.value || new Date(e.publishedAt) > new Date(lastSeen.value),
    ).length,
);

function toggle() {
  open.value = !open.value;
  if (open.value) {
    const now = new Date().toISOString();
    localStorage.setItem(STORAGE_KEY, now);
    lastSeen.value = now;
  }
}
</script>

<template>
  <div ref="panelRef" class="relative">
    <button
      :aria-label="'What\'s new'"
      class="relative rounded-full p-2 hover:bg-neutral-100"
      @click="toggle"
    >
      <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
        <path d="M12 2l2 6 6 2-6 2-2 6-2-6-6-2 6-2 2-6z" />
      </svg>
      <span
        v-if="unreadCount > 0"
        class="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500"
      />
    </button>

    <div
      v-if="open"
      role="dialog"
      class="absolute right-0 mt-2 w-96 max-h-[32rem] overflow-y-auto rounded-lg border bg-white shadow-lg"
    >
      <header class="border-b px-4 py-3">
        <h3 class="font-semibold">What's new</h3>
      </header>
      <ul class="divide-y">
        <li v-for="entry in entries" :key="entry.id" class="px-4 py-3">
          <a :href="entry.url" class="block hover:bg-neutral-50">
            <span
              :class="[
                'inline-block rounded px-2 py-0.5 text-xs font-medium',
                entry.category === 'new' && 'bg-green-100 text-green-800',
                entry.category === 'improved' && 'bg-blue-100 text-blue-800',
                entry.category === 'fixed' && 'bg-yellow-100 text-yellow-800',
                entry.category === 'security' && 'bg-red-100 text-red-800',
              ]"
            >
              {{ entry.category }}
            </span>
            <h4 class="mt-1 font-medium">{{ entry.title }}</h4>
            <p class="mt-1 text-sm text-neutral-600">{{ entry.excerpt }}</p>
            <time class="mt-1 block text-xs text-neutral-500">
              {{ new Date(entry.publishedAt).toLocaleDateString() }}
            </time>
          </a>
        </li>
      </ul>
    </div>
  </div>
</template>

Nuxt 3 users: drop this in components/, Nuxt auto-imports. The onMounted hook protects localStorage from SSR.

Skip 400 lines of boilerplate

ReleaseGlow ships a drop-in widget with read state, dark mode, i18n, and analytics baked in. One script tag, zero maintenance.

What the code above does not handle

The widgets above are ~120 lines each. A production widget is closer to 2,000. Here's the honest gap:

1. Read state per user, not per device

localStorage is per-browser. A user on Chrome desktop and Safari iPhone will see the dot twice. You need server-side "last seen" tied to the user ID, which means an authenticated endpoint and a small table.

2. Dark mode, system preference, theme tokens

The snippet uses Tailwind's dark: classes. Real widgets respect prefers-color-scheme, expose CSS variables for brand color, and cover every combination of light/dark × 4 category badges.

3. Internationalization

Dates render with toLocaleDateString() but category labels, headers, and empty states are hard-coded English. Translating properly means pulling entries in the user's locale (not just the UI).

4. Accessibility

  • Focus trap when the panel opens.
  • Esc to close.
  • aria-live region announcing "N new updates".
  • Keyboard navigation through entries.
  • Reduced-motion respected for the panel slide-in.

This alone is 200 lines and a week of QA.

5. Shadow DOM isolation

If the widget ships as an embed on a customer's site, your Tailwind classes collide with theirs. Shadow DOM + scoped styles are non-negotiable. See our in-app widget architecture guide for the tradeoffs.

6. Analytics

Which entries get clicked? What's the open rate of the panel? What's the dot-clear rate? Without this you're flying blind on release notes feature adoption.

7. Mobile

The snippet uses absolute right-0 mt-2 w-96 which overflows on a 375px viewport. A production widget detects viewport width and switches between popover and full-screen drawer.

8. Notification permission prompts

For users who opt in, native browser notifications on new entries. This means service workers and an opt-in flow.

9. Rate-limited entry fetch

An anonymous visitor shouldn't pull your changelog every 30 seconds. Token bucket per IP, cache headers, stale-while-revalidate.

10. The content pipeline

None of the above matters if your changelog entries don't exist. Someone has to write them, categorize them, and publish them on a cadence. That's a separate problem we cover in How to write great release notes.

Build or buy? The honest cost

Most teams ship the 120-line version in a sprint, then spend the next six months patching the ten items above. The widget itself becomes a small internal maintenance burden that nobody wants to own.

| Dimension | Build yourself | ReleaseGlow widget | |---|---|---| | Time to first ship | 1 week | 5 minutes (one script tag) | | SSR safety (Next, Nuxt) | Debug it | Handled | | Read state per user | Build endpoint + table | Built in | | Dark mode + i18n | 200 LOC each | Tokens exposed | | Accessibility (focus trap, ARIA) | 200 LOC + QA | WCAG 2.1 AA | | Analytics | Hook up Segment | Built in | | Shadow DOM embed mode | Rare expertise | Ships with it | | Maintenance cost | 2–4 hrs / month | $0 |

If your team is four engineers and you're not in the "changelog business", the calculus is obvious. Write the code once in a spike, see that it works, then ship the real thing and get your week back.

Wrap-up

The three snippets in this guide are enough to show a changelog popover in React, Next.js App Router, or Vue 3. They are not enough to ship a widget your users love six months from now.

The build-vs-buy line is clearer than it looks: build the prototype to prove the contract, then decide if the ten items in the gap section are worth your team's attention or your customer's. If they're not, a hosted widget is a one-script-tag problem.

Either way, the code is yours. Copy it, ship it, and keep moving.