Refactoring a Next.js Dashboard: What I'd Do Differently
Three Versions in Three Months
The Helsky Labs Dashboard is a private Next.js 14 app that tracks everything across my indie products -- traffic, revenue, deployments, active visitors. It has been rebuilt three times since November. Not because v1 was bad, but because what I needed from a dashboard changed as the number of products grew from three to eight.
v1 was a basic product list with some Supabase queries. It answered "what products do I have?" but not "which ones are growing?" v2 was a dark theme redesign with simplified navigation -- it looked better but still required clicking through each product to see metrics. v3 is the current version: a full command center with a brand system, real-time data, and a level of visual polish that makes me actually want to open it every morning.
Each version taught me something. This post is about those lessons.
The Tech Stack
The dashboard runs on Next.js 14 with the App Router, Supabase PostgreSQL for data, Tailwind CSS for styling, shadcn/ui as the component foundation, Recharts for data visualization, and Framer Motion for transitions. Authentication is cookie-based -- this is a single-user dashboard, so OAuth would be overkill.
Hosting is Vercel. The database is Supabase's hosted PostgreSQL. The whole thing costs nothing beyond what I am already paying for Supabase on other projects.
What v3 Tackled
Brand System Overhaul
v1 and v2 used default shadcn/ui colors. They looked like every other shadcn dashboard on the internet. For v3, I built a custom dark palette from scratch.
The base color is dark-950: #0d0e12, a near-black with a subtle blue undertone that is easier on the eyes than pure black. Typography uses Inter for UI text and JetBrains Mono for code and metric values. The combination gives the dashboard a terminal-meets-design-tool aesthetic that fits the "command center" concept.
The most impactful brand decision was per-product accent colors. Each product in the Helsky Labs portfolio has its own hex color stored in the database. When you open a product detail page, the entire UI shifts to use that product's accent:
function getProductColorVars(hex: string) {
return {
'--accent-text': hex,
'--accent-bg': `${hex}10`,
'--accent-border': `${hex}30`,
'--accent-hover': `${hex}20`,
'--accent-ring': `${hex}40`,
};
}
These CSS custom properties cascade through the product detail page. Cards, buttons, chart lines, sparklines -- everything picks up the accent. DropVox pages feel blue. BespokeCV pages feel green. Falavra pages feel amber. You know which product you are looking at before reading a single word.
Component-by-Component Rebuild
v3 required re-theming every shadcn/ui component to match the brand specs. Buttons, cards, badges, form controls, dialogs, tabs, toasts -- all of them. This was tedious but necessary. A brand system that only applies to some components creates visual inconsistency that makes the whole thing feel unfinished.
The approach was methodical: I created a checklist of every component used in the dashboard, then went through them one by one, updating colors, border radii, font sizes, and hover states to match the dark palette. No shortcuts, no "I'll fix that one later." Consistency is the whole point of a brand system.
Product Cards with Sparklines
The home page is a health grid -- one card per product showing current status at a glance. Each card includes the product name, a colored accent strip on the left edge, today's visitor count, and a 30-day sparkline trend line.
The sparklines use Recharts' <Area> component with no axes, no labels, no tooltips. Just a tiny line chart showing the traffic shape over the last month. This visual is surprisingly informative. A flat line means stable traffic. An upward slope means growth. A spike followed by a drop means a launch day that did not retain users.
Products and tools have visual distinction on the grid. Products (DropVox, BespokeCV, Falavra) get full-height cards with sparklines. Internal tools (the dashboard itself, shared packages) get compact cards without metrics. The hierarchy communicates what deserves attention.
KPI Stats
At the top of the dashboard, four gradient cards show key performance indicators: total visitors (24h), total page views (7d), monthly revenue, and total subscribers. Each card includes a trend indicator -- an up or down arrow with a percentage showing change from the previous period.
The gradients use the product's accent color blended with the dark base, creating a subtle glow effect:
<div
className="rounded-xl p-6"
style={{
background: `linear-gradient(135deg, ${hex}08 0%, ${hex}15 100%)`,
border: `1px solid ${hex}20`,
}}
>
This is one of those details that takes five minutes to implement but makes the dashboard feel polished rather than utilitarian.
Integration Management
v3 added an integration credentials UI in the settings page. Connecting data sources -- Umami, Stripe, GitHub, Vercel, App Store Connect -- is the hardest part of building a dashboard. The data does not flow until the credentials are in place, and each integration has its own authentication pattern.
The settings page stores API keys and tokens in the integration_credentials table, encrypted at rest by Supabase. The UI provides test buttons for each integration: click "Test" and it makes a lightweight API call to verify the credentials work before saving.
Auto-Sync and Live Refresh
The dashboard syncs data on page load. A status indicator in the header shows whether the sync is in progress, succeeded, or failed. The "last synced" timestamp is derived from the most recent entry in the daily_metrics table, not from a separate sync timestamp -- this means it reflects when data was actually updated, not when a sync was attempted.
Active visitor counts refresh on an interval without page reload. This uses a simple setInterval that calls the Umami realtime API and updates the Zustand store. The interval is generous (30 seconds) because active visitor counts do not need second-by-second precision, and hammering the Umami API would be wasteful.
Mobile Responsiveness
I check the dashboard from my phone more often than I expected. v3 added proper responsive layouts: charts resize to fit mobile viewports (responsive height calculations rather than fixed pixel heights), the product grid stacks to a single column, and the navigation collapses to a hamburger menu.
The most annoying mobile issue was iOS Safari's viewport height behavior. The 100vh value includes the browser chrome on iOS, which means content gets cut off. The fix uses dvh (dynamic viewport height) with a fallback:
.dashboard-container {
min-height: 100dvh;
min-height: -webkit-fill-available;
}
The Database Design
The schema evolved across all three versions, and the current design reflects lessons from each iteration:
-- Core tables
products -- name, slug, platform, revenue_model, pricing, accent_color, status
product_components -- sub-projects within a product (e.g., landing page, API, CLI)
component_deployments -- deployment history per component
daily_metrics -- page_views, unique_visitors, revenue per product per day
revenue_events -- individual Stripe events (charges, refunds, subscriptions)
sync_log -- audit trail for data sync operations
github_connections -- linked GitHub repos per product
integration_credentials -- API keys for external services
app_store_metrics -- iOS/macOS App Store data (downloads, ratings)
The products table is the most important one to get right. It acts as a product registry with rich metadata: platform (web, macOS, iOS, cross-platform), revenue model (free, one-time, subscription, freemium), pricing tier, accent color, and status (active, beta, archived, idea). This metadata drives the UI in ways that were not obvious when I first designed it. The platform field determines which integrations are relevant. The revenue model determines which revenue charts to show. The status field controls visibility on the home grid.
The daily_metrics table uses a composite unique constraint on (product_id, date). Each sync upserts rather than inserts, which means re-running a sync is safe -- it updates existing rows rather than creating duplicates. This was a lesson from v2, where a double-sync bug created duplicate metric rows that inflated the numbers.
Route Structure
The dashboard has five main routes:
/-- Health grid showing all products with sparklines and status/analytics-- Full Umami embed for deep-dive analytics/revenue-- Revenue charts, Stripe events, MRR tracking/products/[slug]-- Per-product detail with accent-colored UI/settings-- Integration credentials, sync controls, preferences
The per-product page (/products/[slug]) is where the accent color system shines. The page fetches the product's hex color from the database and passes it through getProductColorVars(). Every chart, card, and badge on that page uses the product's color. Navigating between products feels like switching contexts visually, which matches the mental model of "I'm looking at DropVox now."
Recharts Patterns
I settled on three chart types after experimenting with more:
Area charts for traffic. Page views and unique visitors over time. The filled area under the line gives a sense of volume that line charts lack. The gradient fill uses the product's accent color.
Bar charts for revenue. Daily or monthly revenue. Bars communicate discrete amounts better than lines. Each bar is clickable and shows the underlying Stripe events for that period.
Sparklines for product cards. Tiny, decoration-only area charts with no axes or labels. These are Recharts components with all chrome stripped away -- <XAxis hide>, <YAxis hide>, <Tooltip content={() => null}>. The result is a pure shape that communicates trend without demanding attention.
The most useful Recharts lesson: set isAnimationActive={false} in production. Chart animations look nice in demos but are distracting when you are scanning a dashboard for information. Static charts load faster and communicate data immediately.
What I Would Do Differently
Start with the brand system, not bolt it on. v1 and v2 used default colors because I wanted to ship quickly. v3 required touching every component to apply the brand. If I had started with even a basic color palette and typography choice, the v3 refactor would have been a theme swap rather than a full rebuild.
Design the database schema first, UI second. In v1, I designed screens and then figured out what data they needed. This led to awkward queries and missing fields. In v3, I designed the schema first -- what data exists, how it relates, what queries the UI will need -- and then built screens to display it. The result is cleaner code and fewer API calls.
Use server components more aggressively. The dashboard fetches most data in client components using useEffect. This made sense in v1 when I was building fast, but v3 should have moved data fetching to server components. The health grid, product detail header, and revenue summary could all be server-rendered. Client components should only handle interactive elements and real-time updates.
Build the integration credentials UI first. In v1 and v2, I hardcoded API keys in environment variables. This worked for one or two integrations but became unmaintainable at five. The credentials UI should have existed from day one -- it is the foundation that everything else depends on. Without connected data sources, a dashboard is just empty charts.
Disable the Next.js Data Cache explicitly. Supabase fetch calls were returning stale data because Next.js caches fetch responses by default in the App Router. I spent an embarrassing amount of time debugging "why are yesterday's metrics showing" before adding cache: 'no-store' to every Supabase call. For a dashboard that needs fresh data, the default caching behavior is the wrong default. I now add cache: 'no-store' to every data-fetching function as a rule:
const { data } = await supabase
.from('daily_metrics')
.select('*')
.eq('product_id', productId)
.order('date', { ascending: false });
// In the fetch wrapper
export async function fetchFromSupabase(query: string) {
return fetch(url, {
cache: 'no-store',
headers: { ... }
});
}
The Compound Effect
The dashboard is not a product. Nobody will ever pay for it. But it has been the single most valuable tool in my indie hacker workflow. The compound effect of seeing all products on one screen, every morning, is that I notice things I would have missed: a traffic spike from a Hacker News mention, a revenue dip from a failed Stripe webhook, a product that has been flat for two weeks and needs attention.
The three rebuilds were not wasted effort. Each one taught me something about dashboard architecture that the previous version could not have taught me. v1 taught me what data matters. v2 taught me that aesthetics affect usage -- I opened the ugly dashboard less often. v3 taught me that brand systems and data models are the foundation, not the decoration.
If you are building internal dashboards or managing multiple indie products, I would enjoy comparing notes. Reach out on LinkedIn or explore what I am building at helrabelo.dev.