Developing a Public Roadmap Page
A public roadmap shows users what the team plans to build, what's in progress, and what's done. It's a trust and retention tool—users see their requests are heard. Implemented as a static page with data from Notion, Linear, Jira, or own database.
Data Sources
Option 1: Notion as Roadmap CMS
// lib/roadmap.ts
import { Client } from '@notionhq/client';
const notion = new Client({ auth: process.env.NOTION_TOKEN });
export interface RoadmapItem {
id: string;
title: string;
status: 'planned' | 'in_progress' | 'done';
quarter: string; // 'Q1 2025'
category: string;
votes: number;
description: string;
}
export async function getRoadmap(): Promise<RoadmapItem[]> {
const response = await notion.databases.query({
database_id: process.env.NOTION_ROADMAP_DB_ID!,
filter: {
property: 'Public',
checkbox: { equals: true },
},
sorts: [{ property: 'Quarter', direction: 'ascending' }],
});
return response.results.map(page => ({
id: page.id,
title: page.properties.Name.title[0]?.plain_text ?? '',
status: page.properties.Status.select?.name?.toLowerCase().replace(' ', '_') as any,
quarter: page.properties.Quarter.select?.name ?? '',
category: page.properties.Category.select?.name ?? '',
votes: page.properties.Votes.number ?? 0,
description: page.properties.Description.rich_text[0]?.plain_text ?? '',
}));
}
Option 2: Own Database
// RoadmapItem Model
Schema::create('roadmap_items', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->enum('status', ['planned', 'in_progress', 'done', 'cancelled'])->default('planned');
$table->string('quarter')->nullable(); // 'Q2 2025'
$table->string('category')->nullable(); // 'Performance', 'Mobile', 'API'
$table->integer('sort_order')->default(0);
$table->boolean('is_public')->default(true);
$table->timestamps();
});
Frontend: Kanban-style Roadmap
// components/Roadmap.tsx
const STATUS_COLUMNS = [
{ key: 'planned', label: 'Planned', color: 'bg-gray-100' },
{ key: 'in_progress', label: 'In Progress', color: 'bg-blue-100' },
{ key: 'done', label: 'Done', color: 'bg-green-100' },
];
export function RoadmapBoard({ items }: { items: RoadmapItem[] }) {
const grouped = STATUS_COLUMNS.map(col => ({
...col,
items: items.filter(i => i.status === col.key),
}));
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{grouped.map(col => (
<div key={col.key}>
<h3 className={`font-semibold px-3 py-2 rounded-t ${col.color}`}>
{col.label} <span className="text-gray-500 font-normal">({col.items.length})</span>
</h3>
<div className="space-y-3 mt-3">
{col.items.map(item => (
<RoadmapCard key={item.id} item={item} />
))}
</div>
</div>
))}
</div>
);
}
function RoadmapCard({ item }: { item: RoadmapItem }) {
return (
<div className="bg-white border rounded-lg p-4 shadow-sm">
{item.category && (
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">{item.category}</span>
)}
<h4 className="font-medium mt-2">{item.title}</h4>
{item.description && <p className="text-sm text-gray-500 mt-1">{item.description}</p>}
{item.quarter && <p className="text-xs text-gray-400 mt-2">{item.quarter}</p>}
</div>
);
}
Filtering and Search
// Client-side filter without page reload
const [filter, setFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const visible = items.filter(item =>
(filter === 'all' || item.category === filter) &&
(search === '' || item.title.toLowerCase().includes(search.toLowerCase()))
);
ISR Caching for Next.js
// pages/roadmap.tsx
export const getStaticProps: GetStaticProps = async () => {
const items = await getRoadmap();
return {
props: { items },
revalidate: 3600, // Regenerate every hour
};
};
Notifying Subscribers of Updates
When status changes to done, automatically send emails to users subscribed to that feature:
public function handle(RoadmapItemStatusChanged $event): void
{
if ($event->item->status !== 'done') return;
$subscribers = $event->item->subscribers()->with('user')->get();
foreach ($subscribers as $sub) {
Mail::to($sub->user->email)->queue(new RoadmapItemCompletedMail($event->item));
}
}
Timeline
Public roadmap with Notion backend or own database, filters, and ISR cache: 3–4 working days.







