Changelog Widget: How to Add Product Updates Inside Your App

A changelog widget brings your product updates directly into your app interface. Instead of users having to visit a separate changelog page (which most never do), important updates appear where they're already working.
The widget can be a notification badge, a slide-out panel, a modal, or an embedded feed. When you ship features users requested, the widget ensures they actually see the announcement.
This guide covers everything from simple iframe embeds to custom API integrations with code examples for React, Vue, and vanilla JavaScript.
Why add a changelog widget
Visibility drives adoption. A new feature that users don't know about might as well not exist. Email announcements get ignored. Blog posts go unread. But a widget inside the app gets seen.
Close the feedback loop. When users request features through feedback tools like Canny or UserJot, the widget shows them you actually built what they asked for. This increases trust and future engagement.
Reduce support tickets. "Is this bug fixed?" "When did you add this feature?" "What changed since last week?" A visible changelog answers these questions before they become tickets.
Drive feature discovery. New features often go unnoticed because they're buried in menus or require specific use cases. The widget surfaces them to all users.
Types of changelog widgets
1. Notification badge
A numbered badge on a "What's New" menu item or help section. Shows count of unread updates.
Best for: Apps with limited screen real estate. The badge creates curiosity without taking space.
Implementation: Simple CSS counter that updates when new changelog entries are published.
2. Slide-out panel
A panel that slides in from the edge of the screen (usually right side) showing recent updates.
Best for: Web apps with sidebars or secondary navigation areas. Common in project management tools, CRMs.
Implementation: Fixed position element with CSS transitions. Triggered by clicking the badge or automatically for important updates.
3. Modal popup
A modal that appears on login or at specific trigger points showing recent updates.
Best for: Major announcements, new features that require explanation. Can be persistent until dismissed.
Implementation: Standard modal component with changelog content. Often triggered once per user per update.
4. Embedded feed
A dedicated section within your app interface showing a scrollable list of updates.
Best for: Apps with dashboard layouts, admin panels where users expect to see system updates.
Implementation: Dedicated component that fetches and displays changelog entries.
5. In-line banner
A temporary banner at the top of key pages announcing specific updates.
Best for: Feature launches, important changes that affect current workflows.
Implementation: Conditional banner component that appears based on update relevance and user dismissal state.
Implementation approaches
Option 1: Iframe embed (easiest)
Most changelog tools provide an embeddable iframe. Copy the code, paste it in your app.
<iframe
src="https://changelog.yourapp.com/embed"
width="400"
height="600"
frameborder="0">
</iframe>
Pros:
- Zero development time
- Tool handles styling, updates, everything
- Works with any changelog platform
Cons:
- Limited customization
- Doesn't match your app's design
- Loading performance depends on external service
- No control over triggering logic
Option 2: API integration (most flexible)
Fetch changelog data via API and render with your own components. Full control over design and behavior.
Example with React:
import { useState, useEffect } from 'react';
function ChangelogWidget({ apiKey }) {
const [entries, setEntries] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
fetchChangelog();
}, []);
const fetchChangelog = async () => {
try {
const response = await fetch('/api/changelog/recent', {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
setEntries(data.entries);
setUnreadCount(data.unread_count);
} catch (error) {
console.error('Failed to fetch changelog:', error);
}
};
const markAsRead = async (entryId) => {
try {
await fetch(`/api/changelog/${entryId}/read`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'User-ID': getCurrentUserId() // Your user identification
}
});
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (error) {
console.error('Failed to mark as read:', error);
}
};
return (
<div className="changelog-widget">
{/* Notification badge */}
<button
onClick={() => setIsOpen(true)}
className="changelog-trigger"
>
What's New
{unreadCount > 0 && (
<span className="badge">{unreadCount}</span>
)}
</button>
{/* Slide-out panel */}
{isOpen && (
<div className="changelog-panel">
<div className="changelog-header">
<h3>What's New</h3>
<button onClick={() => setIsOpen(false)}>×</button>
</div>
<div className="changelog-entries">
{entries.map(entry => (
<div
key={entry.id}
className={`entry ${!entry.read ? 'unread' : ''}`}
onClick={() => markAsRead(entry.id)}
>
<div className="entry-date">
{new Date(entry.published_at).toLocaleDateString()}
</div>
<h4>{entry.title}</h4>
<div className="entry-content">
{entry.summary}
</div>
{entry.category && (
<span className="category">{entry.category}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
Vue.js example:
<template>
<div class="changelog-widget">
<button @click="togglePanel" class="changelog-trigger">
What's New
<span v-if="unreadCount > 0" class="badge">{{ unreadCount }}</span>
</button>
<div v-if="isOpen" class="changelog-panel">
<div class="changelog-header">
<h3>What's New</h3>
<button @click="isOpen = false">×</button>
</div>
<div class="changelog-entries">
<div
v-for="entry in entries"
:key="entry.id"
:class="['entry', { unread: !entry.read }]"
@click="markAsRead(entry.id)"
>
<div class="entry-date">
{{ formatDate(entry.published_at) }}
</div>
<h4>{{ entry.title }}</h4>
<div class="entry-content">{{ entry.summary }}</div>
<span v-if="entry.category" class="category">
{{ entry.category }}
</span>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ChangelogWidget',
data() {
return {
entries: [],
unreadCount: 0,
isOpen: false
};
},
mounted() {
this.fetchChangelog();
},
methods: {
async fetchChangelog() {
try {
const response = await fetch('/api/changelog/recent');
const data = await response.json();
this.entries = data.entries;
this.unreadCount = data.unread_count;
} catch (error) {
console.error('Failed to fetch changelog:', error);
}
},
async markAsRead(entryId) {
try {
await fetch(`/api/changelog/${entryId}/read`, {
method: 'POST',
headers: { 'User-ID': this.getCurrentUserId() }
});
this.unreadCount = Math.max(0, this.unreadCount - 1);
} catch (error) {
console.error('Failed to mark as read:', error);
}
},
togglePanel() {
this.isOpen = !this.isOpen;
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString();
}
}
};
</script>
Vanilla JavaScript example:
class ChangelogWidget {
constructor(apiKey, containerId) {
this.apiKey = apiKey;
this.container = document.getElementById(containerId);
this.entries = [];
this.unreadCount = 0;
this.isOpen = false;
this.init();
}
async init() {
await this.fetchChangelog();
this.render();
this.attachEvents();
}
async fetchChangelog() {
try {
const response = await fetch('/api/changelog/recent', {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
this.entries = data.entries;
this.unreadCount = data.unread_count;
} catch (error) {
console.error('Failed to fetch changelog:', error);
}
}
render() {
const badgeHtml = this.unreadCount > 0
? `<span class="badge">${this.unreadCount}</span>`
: '';
const entriesHtml = this.entries.map(entry => `
<div class="entry ${!entry.read ? 'unread' : ''}" data-id="${entry.id}">
<div class="entry-date">
${new Date(entry.published_at).toLocaleDateString()}
</div>
<h4>${entry.title}</h4>
<div class="entry-content">${entry.summary}</div>
${entry.category ? `<span class="category">${entry.category}</span>` : ''}
</div>
`).join('');
this.container.innerHTML = `
<button class="changelog-trigger">
What's New ${badgeHtml}
</button>
<div class="changelog-panel" style="display: ${this.isOpen ? 'block' : 'none'}">
<div class="changelog-header">
<h3>What's New</h3>
<button class="close-btn">×</button>
</div>
<div class="changelog-entries">${entriesHtml}</div>
</div>
`;
}
attachEvents() {
const trigger = this.container.querySelector('.changelog-trigger');
const closeBtn = this.container.querySelector('.close-btn');
const entries = this.container.querySelectorAll('.entry');
trigger.addEventListener('click', () => this.togglePanel());
closeBtn.addEventListener('click', () => this.closePanel());
entries.forEach(entry => {
entry.addEventListener('click', () => {
const entryId = entry.dataset.id;
this.markAsRead(entryId);
});
});
}
togglePanel() {
this.isOpen = !this.isOpen;
this.render();
this.attachEvents();
}
closePanel() {
this.isOpen = false;
this.render();
this.attachEvents();
}
async markAsRead(entryId) {
try {
await fetch(`/api/changelog/${entryId}/read`, {
method: 'POST',
headers: { 'User-ID': this.getCurrentUserId() }
});
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.render();
this.attachEvents();
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
}
// Usage
const widget = new ChangelogWidget('your-api-key', 'changelog-widget-container');
Option 3: Webhook integration
Some changelog tools send webhooks when new entries are published. Catch these webhooks and trigger your own notifications.
// Express.js webhook handler
app.post('/webhook/changelog', (req, res) => {
const { entry } = req.body;
// Trigger in-app notifications
notifyUsers({
type: 'changelog_update',
title: entry.title,
summary: entry.summary,
url: entry.url
});
res.status(200).send('OK');
});
Styling considerations
Match your app's design
The widget should feel native to your app, not like an external embed.
.changelog-widget {
font-family: var(--app-font-family);
--primary-color: var(--app-primary-color);
--text-color: var(--app-text-color);
--background-color: var(--app-background-color);
}
.changelog-panel {
position: fixed;
top: 0;
right: 0;
width: 400px;
height: 100vh;
background: var(--background-color);
border-left: 1px solid var(--border-color);
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.changelog-panel.open {
transform: translateX(0);
}
.entry.unread {
border-left: 3px solid var(--primary-color);
background: var(--highlight-background);
}
.badge {
background: var(--error-color);
color: white;
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
margin-left: 8px;
}
Mobile responsiveness
Ensure the widget works on mobile devices.
@media (max-width: 768px) {
.changelog-panel {
width: 100vw;
height: 80vh;
top: auto;
bottom: 0;
transform: translateY(100%);
}
.changelog-panel.open {
transform: translateY(0);
}
}
Loading states
Show loading indicators while fetching changelog data.
.changelog-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: var(--text-muted);
}
.changelog-loading::after {
content: '';
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top: 2px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
User experience best practices
1. Respect user attention
Don't show the widget immediately on every page load. Trigger it thoughtfully:
- On login, if there are unread updates
- When navigating to areas affected by recent changes
- Once per user per major announcement
2. Make it dismissible
Always provide a clear way to close the widget and mark updates as read.
3. Categorize updates
Show different icons or colors for different types of updates:
- 🎉 New features
- 🐛 Bug fixes
- ⚡ Performance improvements
- 🔄 Interface changes
4. Provide context
For feature announcements, link to documentation, tutorials, or the feature itself.
5. Track engagement
Monitor which updates get the most engagement to improve future announcements.
// Track widget interactions
function trackEvent(action, entry_id = null) {
analytics.track('Changelog Widget', {
action: action, // 'opened', 'entry_clicked', 'dismissed'
entry_id: entry_id,
timestamp: new Date().toISOString()
});
}
Common pitfalls to avoid
Widget feels like spam
Problem: Showing every minor update disrupts user workflow. Solution: Be selective. Only promote updates that actually matter to users.
Poor mobile experience
Problem: Desktop-designed widgets break on mobile. Solution: Test on mobile devices. Consider bottom sheet patterns for mobile.
Slow loading
Problem: Widget fetches block page rendering. Solution: Load asynchronously. Show skeleton/loading states.
Breaking app security
Problem: Iframe embeds or API calls bypass your security model. Solution: Validate all external content. Use Content Security Policy headers.
Inconsistent styling
Problem: Widget looks foreign to your app design. Solution: Use your app's CSS variables, fonts, and design tokens.
Platform-specific examples
Worknotes integration
If you use Worknotes for changelog generation from Linear, here's how to add a widget:
// Fetch recent entries from Worknotes API
async function fetchWorknotes() {
const response = await fetch('https://api.worknotes.ai/v1/entries/recent', {
headers: {
'Authorization': `Bearer ${process.env.WORKNOTES_API_KEY}`,
'X-Project-ID': 'your-project-id'
}
});
return response.json();
}
Beamer integration
Beamer provides multiple widget types. The simplest embed:
<script>
var beamerConfig = {
product_id: 'YOUR_PRODUCT_ID',
selector: '#beamer-widget-trigger'
};
</script>
<script src="https://app.getbeamer.com/js/beamer-embed.js"></script>
<button id="beamer-widget-trigger">What's New</button>
Headway integration
Headway offers a notification badge widget:
<script>
window.HW_config = {
selector: ".hw-badge",
account: "YOUR_ACCOUNT_ID"
}
</script>
<script async src="https://cdn.headwayapp.co/widget.js"></script>
<div class="hw-badge">Updates</div>
Performance optimization
Lazy loading
Don't load the widget until user interaction:
let widgetLoaded = false;
document.getElementById('changelog-trigger').addEventListener('click', async () => {
if (!widgetLoaded) {
await loadChangelogWidget();
widgetLoaded = true;
}
showWidget();
});
Caching
Cache changelog data to reduce API calls:
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
let cache = {
data: null,
timestamp: 0
};
async function getCachedChangelog() {
const now = Date.now();
if (cache.data && (now - cache.timestamp) < CACHE_DURATION) {
return cache.data;
}
cache.data = await fetchChangelog();
cache.timestamp = now;
return cache.data;
}
Minimize bundle size
Only load widget code when needed. Use dynamic imports:
async function initWidget() {
const { ChangelogWidget } = await import('./changelog-widget.js');
const widget = new ChangelogWidget(config);
widget.render();
}
Analytics and measurement
Track widget effectiveness:
// Widget performance metrics
const metrics = {
widget_shown: 0,
entries_clicked: 0,
features_adopted: 0 // Track if users actually use announced features
};
// Track feature adoption after announcement
function trackFeatureUsage(featureId) {
if (userSawAnnouncement(featureId)) {
metrics.features_adopted++;
analytics.track('Feature Adopted Post-Announcement', { featureId });
}
}
Getting started
-
Choose your approach: Iframe embed for speed, API integration for control.
-
Pick your trigger: Notification badge, automatic popup, or menu item.
-
Design the experience: Modal, slide-out panel, or embedded feed.
-
Implement tracking: Monitor engagement and feature adoption.
-
Test thoroughly: Different screen sizes, loading states, error conditions.
-
Launch gradually: A/B test with a subset of users first.
The goal isn't just to show updates, but to drive awareness and adoption of new features. A well-implemented changelog widget turns product improvements into user engagement.
Worknotes generates changelog entries from Linear and provides widget APIs for in-app integration. $29/month flat. Start your free trial →
A better way to share product updates
Worknotes is a platform for creating and sharing product updates across changelogs, email, and in-app announcements, without slowing down your team.


