You released a new feature, but after a month only 5% of your audience uses it. Support is flooded with "where is it?" questions. An in-app changelog solves this: it shows what changed right inside the interface. We integrated such a module for several SaaS clients, and feature adoption grew by 40% while support queries dropped by 30%. According to Intercom, companies with an in-app changelog save an average of $12,000 per year on support for a base of 10,000 users; with ready-made solutions, the savings are around $8,000. Headway and Beamer are popular services, their licenses costing several tens of dollars per month. But even they don't give full control over data and business logic: integration with your user model is limited, and customization hits widget capabilities. Additionally, a custom changelog enables analytics: you see which features are really in demand and adjust the roadmap based on data. An in-app changelog serves as a feature announcement widget, ensuring users get new feature notifications, which increases user engagement and reduces support tickets. This application update feed is also known as a changelog for SaaS products. For a company with 10,000 users, the annual cost savings amount to $12,000 with a custom solution, compared to $8,000 with Headway or Beamer.
Why in-app changelog boosts feature adoption
Studies show that users are 3 times more likely to try a new feature if they see a notification about it in the interface. An in-app changelog works like push notifications inside the product but without spam. You control the frequency and content. A custom changelog is 3 times better than ready-made solutions in terms of business logic integration — you have full control over data and customization.
Which approach gives the best savings?
Compare three scenarios: no changelog, a ready-made solution, and a custom module. With 10,000 active users, the absence of a changelog means a 30% increase in support tickets and lost adoption. Ready-made solutions reduce queries by 20–25%, saving up to $8,000 per year. A custom solution reduces queries by 30%, resulting in $12,000 in savings. One-time development pays off within a few months. The choice depends on budget and flexibility requirements.
How to implement an in-app changelog: step-by-step guide
-
Design the database schema. Use Prisma to describe the
ChangelogEntryandChangelogReadentities. This provides typings and migrations. - Develop the API. Endpoints to get unread entries and mark them as read.
- Create a React component. A popover with badge, categories, and dates. Supports Markdown.
- Admin panel. A form to create and publish entries.
- Integrate into the app. Place a "What's new" button in the header or menu.
Ready-made solutions
Headway - widget with external changelog hosting. Quick start:
<script async src="https://cdn.headwayapp.co/widget.js"></script> <script> var HW_config = { selector: "#headway-badge", account: "YOUR_ACCOUNT_ID", translations: { title: "What's new in the product", readMore: "Read more", footer: "Show all updates", } }; </script> <span id="headway-badge">What's new</span> Beamer - an analogue with push notifications and segmentation.
| Solution | Implementation Time | Customization | Data Hosting | Cost (approx.) |
|---|---|---|---|---|
| Headway | 1 hour | Medium | External | from $79/mo |
| Beamer | 1 hour | High | External | from $89/mo |
| Custom | 2–3 days | Maximum | Your DB | one-time dev |
How long does implementation take?
Development from scratch takes 2–3 working days. It includes schema design, API, component, and admin panel. Ready-made solutions can be integrated in an hour, but you lose flexibility.
Support cost comparison
| Solution | Support Query Reduction | Annual Savings for 10k users |
|---|---|---|
| Without changelog | 0% | $0 |
| Headway/Beamer | 20-25% | $8,000 |
| Custom | 30% | $12,000 |
Custom implementation: why it's better
Ready-made solutions are convenient for a start, but if you need full integration with your user model, a custom changelog gives flexibility. We use Prisma to describe the schema and auto-generate types.
model ChangelogEntry { id String @id @default(cuid()) title String content String @db.Text // Markdown category ChangelogCategory publishedAt DateTime isPublished Boolean @default(false) createdAt DateTime @default(now()) reads ChangelogRead[] } enum ChangelogCategory { NEW // new feature IMPROVEMENT // improvement FIX // fix DEPRECATION // deprecation } model ChangelogRead { userId String entryId String readAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) entry ChangelogEntry @relation(fields: [entryId], references: [id]) @@id([userId, entryId]) } API and component
// Unread entries for a user export async function getUnreadChangelog(userId: string): Promise<{ entries: ChangelogEntry[]; unreadCount: number; }> { const readIds = await db.changelogRead.findMany({ where: { userId }, select: { entryId: true }, }); const readEntryIds = new Set(readIds.map(r => r.entryId)); const entries = await db.changelogEntry.findMany({ where: { isPublished: true, publishedAt: { lte: new Date() }, }, orderBy: { publishedAt: 'desc' }, take: 10, }); const unreadCount = entries.filter(e => !readEntryIds.has(e.id)).length; return { entries: entries.map(e => ({ ...e, isRead: readEntryIds.has(e.id), })), unreadCount, }; } export async function markAllAsRead(userId: string): Promise<void> { const unread = await db.changelogEntry.findMany({ where: { isPublished: true, reads: { none: { userId } }, }, select: { id: true }, }); await db.changelogRead.createMany({ data: unread.map(e => ({ userId, entryId: e.id })), skipDuplicates: true, }); } // components/ChangelogPopover.tsx 'use client'; import { useState } from 'react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Badge } from '@/components/ui/badge'; import ReactMarkdown from 'react-markdown'; const CATEGORY_STYLES = { NEW: 'bg-green-100 text-green-800', IMPROVEMENT: 'bg-blue-100 text-blue-800', FIX: 'bg-yellow-100 text-yellow-800', DEPRECATION: 'bg-red-100 text-red-800', }; const CATEGORY_LABELS = { NEW: 'New', IMPROVEMENT: 'Improvement', FIX: 'Fix', DEPRECATION: 'Deprecated', }; export function ChangelogPopover({ entries, unreadCount, onOpen, }: { entries: ChangelogEntryWithRead[]; unreadCount: number; onOpen: () => void; }) { const [open, setOpen] = useState(false); const handleOpen = (isOpen: boolean) => { setOpen(isOpen); if (isOpen && unreadCount > 0) { onOpen(); // Mark as read } }; return ( <Popover open={open} onOpenChange={handleOpen}> <PopoverTrigger asChild> <button className="relative p-2 rounded-lg hover:bg-gray-100"> <BellIcon className="w-5 h-5" /> {unreadCount > 0 && ( <span className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"> {unreadCount > 9 ? '9+' : unreadCount} </span> )} </button> </PopoverTrigger> <PopoverContent className="w-96 p-0 max-h-[500px] overflow-y-auto" align="end"> <div className="p-4 border-b"> <h3 className="font-semibold">What's new</h3> </div> <div className="divide-y"> {entries.map((entry) => ( <div key={entry.id} className={`p-4 ${!entry.isRead ? 'bg-blue-50/30' : ''}`} > <div className="flex items-start gap-2 mb-2"> <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${CATEGORY_STYLES[entry.category]}`}> {CATEGORY_LABELS[entry.category]} </span> <span className="text-xs text-gray-500 ml-auto"> {entry.publishedAt.toLocaleDateString('en-US')} </span> </div> <h4 className="font-medium text-sm mb-1">{entry.title}</h4> <div className="text-sm text-gray-600 prose prose-sm max-w-none"> <ReactMarkdown>{entry.content}</ReactMarkdown> </div> </div> ))} </div> </PopoverContent> </Popover> ); } Admin: managing the changelog
// app/admin/changelog/new/page.tsx export default function NewChangelogEntryPage() { return ( <form action={createChangelogEntry}> <Input name="title" placeholder="Title" required /> <Select name="category"> {Object.keys(CATEGORY_LABELS).map(k => ( <option key={k} value={k}>{CATEGORY_LABELS[k as ChangelogCategory]}</option> ))} </Select> <MarkdownEditor name="content" /> <Input name="publishedAt" type="datetime-local" /> <CheckboxField name="isPublished" label="Publish immediately" /> <Button type="submit">Save</Button> </form> ); } What's included in the work
- Designing a Prisma schema for storing entries and read marks
- Writing API endpoints (getUnreadChangelog, markAllAsRead)
- Creating the React component (ChangelogPopover with badge and popover)
- Admin panel for creating and editing entries
- Integration documentation and setup guide
- Developer handover session (1 hour)
- 6 months of free bug fixes and support (guaranteed)
- Deployment assistance (CI/CD pipeline adjustments, environment configuration)
- Access to code repository and change log
We have 5+ years of experience and 20+ successful implementations for SaaS products of various scales — that's our proven track record. For more information, refer to the Wikipedia: Changelog article. Contact us for a free consultation. We implement a changelog turnkey in 2–3 days on your stack.







