Building a Comprehensive Product Roadmap Management System with Next.js and TanStack Table
Discover how EliteSaaS built a powerful product roadmap management system using Next.js, TanStack Table, and PostgreSQL. Explore advanced admin tools, timezone-safe date handling, and multiple public views. Perfect for SaaS teams seeking scalable solutions.

Product roadmaps are the backbone of transparent product development, but building a feature-rich roadmap system that handles both admin management and public viewing can be surprisingly complex. Today, I'll walk you through how we built a comprehensive roadmap management system for EliteSaaS that tackles everything from timezone handling to advanced table functionality.
The Challenge: Beyond Basic CRUD
Most roadmap implementations are simple lists, but we needed something more sophisticated:
- Dual Interface Design: Powerful admin tools + beautiful public views
- Advanced Table Management: Sorting, filtering, pagination with professional UX
- Timezone-Safe Date Handling: Because "May 17th" should be May 17th everywhere
- Multiple View Types: Kanban boards, timeline views, and card layouts
- Comprehensive Tracking: Both estimated and actual completion dates
- Real-time Updates: Instant UI feedback for all operations
Technical Architecture Overview
Our roadmap system is built on a modern stack:
// Core Stack - Next.js 15 (App Router) - Supabase (PostgreSQL + RLS) - TanStack Table v8 (Advanced table functionality) - TypeScript (End-to-end type safety) - Tailwind CSS + shadcn/ui (Design system) - Zustand (State management)
Database Schema Design
The foundation starts with a robust PostgreSQL schema:
CREATE TABLE roadmap_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title VARCHAR(255) NOT NULL, description TEXT NOT NULL, status VARCHAR(20) DEFAULT 'planned', -- completed, in-progress, planned, cancelled priority VARCHAR(20) DEFAULT 'medium', -- low, medium, high, critical category VARCHAR(100) NOT NULL, quarter VARCHAR(20), -- "Q1 2025" or NULL for TBD estimated_completion DATE, -- DATE type (not timestamp!) completed_at DATE, -- DATE type (not timestamp!) sort_order INTEGER DEFAULT 0, is_public BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() );
The Date Type Decision
One of our biggest architectural decisions was using PostgreSQL's DATE
type instead of TIMESTAMP
for completion dates. Here's why:
// ❌ TIMESTAMP approach (timezone nightmare) completed_at: "2025-05-17T04:00:00.000Z"; // At 2:31 AM EST, this becomes May 16th 11:00 PM // ✅ DATE approach (timezone-safe) completed_at: "2025-05-17"; // Always displays as May 17th, regardless of timezone
This seemingly simple change eliminated an entire class of timezone-related bugs that plague date-heavy applications.
Advanced Table Management with TanStack Table
For the admin interface, we needed professional-grade table functionality. TanStack Table v8 provided the perfect foundation:
Column Configuration
const columns: ColumnDef<RoadmapItem>[] = [ { accessorKey: "title", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Title" /> ), cell: ({ row }) => { const item = row.original; return ( <div className="max-w-[300px]"> <p className="font-medium truncate">{item.title}</p> <p className="text-sm text-muted-foreground line-clamp-2"> {item.description} </p> </div> ); }, }, { accessorKey: "estimated_completion", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Est. Completion" /> ), cell: ({ row }) => { const date = row.getValue("estimated_completion") as string | null; if (!date) return <div className="text-muted-foreground">—</div>; return <div className="text-sm">{formatDateString(date)}</div>; }, }, // ... more columns ];
Smart Filtering and Search
The table includes intelligent filtering capabilities:
// Status-based filtering filterFn: (row, id, value) => { return value.includes(row.getValue(id)); }, ( // Global search across title and description <DataTableToolbar table={table} searchKey="title" searchPlaceholder="Search roadmap items..." /> );
Responsive Design Considerations
One challenge with data tables is horizontal scrolling on mobile. We solved this with:
// Sticky headers with proper scrolling <ScrollArea className="h-[400px] w-full rounded-md border"> <Table className="relative"> <TableHeader className="sticky top-0 bg-background z-10"> {/* Headers */} </TableHeader> <TableBody>{/* Rows */}</TableBody> </Table> <ScrollBar orientation="horizontal" /> </ScrollArea>
Dual Date Tracking System
A sophisticated roadmap needs both planning and reality tracking:
Estimated vs Actual Completion
interface RoadmapFormData { estimated_completion: string | null; // Planning date completed_at: string | null; // Reality date } // Auto-populate actual completion when status changes const handleStatusChange = (value: string) => { updateFormData("status", value); if (value === "completed" && !formData.completed_at) { const today = new Date().toISOString().split("T")[0]; updateFormData("completed_at", today); } };
Smart Form Behavior
The completion date field only appears when relevant:
{ formData.status === "completed" && ( <div className="space-y-2"> <Label htmlFor="completed_at">Actual Completion Date</Label> <Input id="completed_at" type="date" value={formatDateForInput(formData.completed_at)} onChange={(e) => updateFormData("completed_at", e.target.value)} /> </div> ); }
Solving the Timezone Problem
The most insidious issue we faced was timezone conversion during date display. Even with DATE-only database storage, JavaScript's new Date()
constructor was causing problems:
The Problem
// ❌ This creates timezone issues const date = "2025-05-17"; // DATE from database new Date(date).toLocaleDateString(); // Returns "5/16/2025" at 2:31 AM EST!
The Solution
Instead of converting to Date objects, we implemented direct string formatting:
// ✅ Timezone-safe date formatting export const formatDateString = (dateString: string): string => { const [year, month, day] = dateString.split("-"); const monthNames = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; return `${monthNames[parseInt(month) - 1]} ${parseInt(day)}, ${year}`; }; // Usage across all components { formatDateString(item.completed_at); } // Always correct!
This approach is:
- Bulletproof: No timezone math involved
- Consistent: Same format everywhere
- Performant: Simple string manipulation vs Date object creation
- Predictable: "2025-05-17" always becomes "May 17, 2025"
Multiple View Types for Public Display
The public roadmap offers three distinct viewing experiences:
1. Kanban Board View
Perfect for status-based organization:
const groupedItems = roadmapItems.reduce((acc, item) => { if (!acc[item.status]) acc[item.status] = []; acc[item.status].push(item); return acc; }, {} as Record<string, RoadmapItem[]>); const columnOrder = ["planned", "in-progress", "completed", "cancelled"];
2. Timeline View
Chronological organization by quarters:
const sortedItems = [...roadmapItems].sort((a, b) => { // Handle "TBD" and empty quarters if (a.quarter === "TBD") return 1; if (b.quarter === "TBD") return -1; // Parse "Q1 2025" format for proper sorting const aMatch = a.quarter?.match(/Q(\d+) (\d+)/); const bMatch = b.quarter?.match(/Q(\d+) (\d+)/); if (aMatch && bMatch) { const aYear = parseInt(aMatch[2]); const bYear = parseInt(bMatch[2]); if (aYear !== bYear) return aYear - bYear; return parseInt(aMatch[1]) - parseInt(bMatch[1]); } return a.sort_order - b.sort_order; });
3. Card View
Information-dense layout with visual priority indicators:
const priorityConfig = { critical: { icon: Zap, color: "bg-red-500/10 text-red-700" }, high: { icon: Target, color: "bg-orange-500/10 text-orange-700" }, medium: { icon: Calendar, color: "bg-yellow-500/10 text-yellow-700" }, low: { icon: Circle, color: "bg-gray-500/10 text-gray-700" }, };
Code Quality and Maintainability
DRY Principle Application
Initially, we had the same date formatting function in four different files. We refactored to a shared utility:
// Before: Copy-pasted 4 times ❌ const formatDateString = (dateString: string) => { /* ... */ }; // After: Single source of truth ✅ // apps/web/src/lib/utils/date.ts export const formatDateString = (dateString: string): string => { // Implementation once, used everywhere };
TypeScript Integration
Strong typing throughout the system prevents runtime errors:
export interface RoadmapItem { id: string; title: string; status: "completed" | "in-progress" | "planned" | "cancelled"; priority: "low" | "medium" | "high" | "critical"; estimated_completion: string | null; // YYYY-MM-DD completed_at: string | null; // YYYY-MM-DD // ... other fields }
Component Composition
The dialog system uses a single component for both create and edit modes:
interface RoadmapItemDialogProps { mode: "create" | "edit"; item?: RoadmapItem; // Required for edit mode children: React.ReactNode; onItemChanged?: () => void; } // Dynamic behavior based on mode const Icon = mode === "create" ? Plus : Edit; const title = mode === "create" ? "Create Roadmap Item" : "Edit Roadmap Item";
Performance Optimizations
Efficient State Management
Loading states prevent multiple API calls:
const [updatingVisibility, setUpdatingVisibility] = useState<Set<string>>( new Set() ); const togglePublic = async (id: string, currentPublic: boolean) => { if (updatingVisibility.has(id)) return; // Prevent duplicate calls setUpdatingVisibility((prev) => new Set([...prev, id])); try { await updateItem(id, { is_public: !currentPublic }); } finally { setUpdatingVisibility((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); } };
Optimistic UI Updates
The system provides immediate feedback before server confirmation:
// Show loading state immediately <Button disabled={isUpdating}> {isUpdating ? ( <Loader2 className="w-4 h-4 animate-spin" /> ) : ( <Eye className="w-4 h-4" /> )} </Button>
Lessons Learned
- Date handling is deceptively complex - Always consider timezone implications early
- User experience details matter - Loading states and responsive design make the difference
- Code duplication creeps in fast - Regular refactoring to shared utilities pays off
- TypeScript catches edge cases - Strong typing prevents many runtime errors
- Progressive enhancement works - Start with basic functionality, add advanced features incrementally
Next Steps
Future enhancements we're considering:
- Drag-and-drop reordering in Kanban view
- Bulk operations for status changes
- Integration with GitHub issues for automatic updates
- Email notifications for roadmap changes
- Public API for third-party integrations
- Advanced filtering by date ranges and custom fields
Technical Specs Summary
Architecture: - Next.js 15 App Router - PostgreSQL with Row Level Security - TanStack Table v8 - TypeScript throughout - Tailwind CSS + shadcn/ui Key Features: - Dual date tracking (estimated vs actual) - Three public view types (kanban, timeline, cards) - Advanced table with sorting/filtering/pagination - Timezone-safe date handling - Real-time UI feedback - Responsive design - Type-safe API layer Performance: - Optimistic UI updates - Efficient state management - Minimal re-renders - Code splitting by view type
Try It Yourself
We'd love to get your feedback on this roadmap system! You can see the live demo roadmap here and learn more about the roadmap system to see all the features in action.
Some questions for the community:
- What roadmap features are most important to your users?
- How do you handle timezone issues in date-heavy applications?
- Have you implemented similar table functionality? What challenges did you face?
- What would you add to make this roadmap system even better?
Drop us a line at hello@elitesaas.dev or connect with us on Twitter. We're always looking to improve and would love to hear about your experiences building similar systems!
Want to build something similar? The techniques in this post apply to any data-heavy application with complex table requirements. The key is starting with solid architectural decisions and progressively enhancing the user experience.