Building an Indie Hacker Command Center
The Tab-Switching Nightmare
At some point last month, I counted the tabs. Umami analytics for DropVox. Umami analytics for BespokeCV. Stripe dashboard. Resend audience stats. Repeat for every product. I had 14 tabs open just to answer one question: which of my products is actually gaining traction?
I run Helsky Labs, my indie hacker umbrella for launching products. DropVox, BespokeCV, BookBit, TokenCentric, Gitography, DaysAsNumbers, helrabelo.dev, the Helsky Labs site itself -- eight products and growing. Each one has its own analytics, its own payment provider, its own email list. The data exists, but it's scattered across half a dozen dashboards that were never designed to talk to each other.
The indie hacker philosophy is "ship fast, validate faster." But validation requires looking at metrics. And if looking at metrics takes 15 minutes of tab-switching every morning, you stop doing it. You start guessing instead of measuring. That's how products die quietly -- not from lack of users, but from lack of attention.
I needed a command center. One screen to see everything.
Designing the Data Model
Before writing any UI code, I needed to think about what data matters and how to store it. Three data sources, three types of metrics:
- Traffic -- from Umami (self-hosted analytics)
- Revenue -- from Stripe (payment processing)
- Subscribers -- from Resend (email marketing)
The schema ended up being surprisingly simple. Two core tables do most of the work:
-- Every product I'm tracking
create table products (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
domain text,
umami_site_id text,
stripe_account_id text,
resend_audience_id text,
status text default 'active',
launched_at date,
created_at timestamptz default now()
);
-- Daily aggregated metrics
create table daily_metrics (
id uuid primary key default gen_random_uuid(),
product_id uuid references products not null,
date date not null,
page_views int default 0,
unique_visitors int default 0,
gross_revenue int default 0,
refunds int default 0,
net_revenue int default 0,
total_subscribers int default 0,
new_subscribers int default 0,
unsubscribes int default 0,
created_at timestamptz default now(),
unique(product_id, date)
);
The products table is a registry. Each row knows how to find its data -- the Umami site ID for traffic, the Stripe account for revenue, the Resend audience for subscribers. Not every product has all three. DaysAsNumbers has no Stripe account because it sells through the App Store. TokenCentric is free, so no payment integration at all. The nullable foreign keys handle this gracefully.
The daily_metrics table is the aggregation layer. One row per product per day. This design means I never query raw event data for the dashboard -- everything is pre-aggregated, which keeps the UI fast even as data accumulates over months.
I also added a revenue_events table for real-time Stripe webhooks, but for the MVP, the daily aggregation is what powers most of the interface.
The Stack
I went with what I know and what ships fast:
- Next.js 14 with App Router -- file-based routing, server components, API routes all in one
- Supabase PostgreSQL -- hosted database with a generous free tier and a great client library
- Tailwind CSS + shadcn/ui -- consistent, composable components without fighting a design system
- Recharts -- React charting library that plays nicely with server-side rendering
- Framer Motion -- subtle animations to make the interface feel alive
Nothing exotic. The stack is boring by design, which is exactly what you want when the goal is to ship in a sprint, not evaluate technology.
Pulling Data from Umami
My self-hosted Umami instance at analytics.helsky-labs.com tracks all my products. Umami exposes a REST API that returns pageviews, unique visitors, and session data per site. A daily cron job pulls yesterday's stats and writes them to the daily_metrics table:
// Simplified version of the sync logic
async function syncUmamiStats(product: Product) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const stats = await fetch(
`${UMAMI_API_URL}/api/websites/${product.umami_site_id}/stats`,
{
headers: { Authorization: `Bearer ${UMAMI_API_TOKEN}` },
// Query params for date range
}
);
const data = await stats.json();
await supabase.from('daily_metrics').upsert({
product_id: product.id,
date: yesterday.toISOString().split('T')[0],
page_views: data.pageviews.value,
unique_visitors: data.visitors.value,
});
}
The cron endpoint lives at /api/sync/umami and runs once daily via Vercel's cron configuration. The upsert with the unique constraint on (product_id, date) makes it idempotent -- if it runs twice, no duplicate data.
But here is the interesting part: for today's stats, I don't wait for the cron. The dashboard fetches live data directly from the Umami API on each page load. Yesterday and before comes from Supabase (fast, pre-aggregated). Today comes from Umami in real-time. The UI stitches them together seamlessly.
The Dashboard Layout
The main dashboard is a bird's-eye view. At the top, four stat cards show aggregate numbers:
- Total Visitors (today, across all products)
- Total Revenue (this month)
- Total Subscribers (current)
- Active Products (count)
Below that, a grid of product cards. Each card shows the product name, its logo, key metrics, and a sparkline showing the traffic trend over the last 30 days. You can sort by revenue, traffic, or most recent activity. You can filter by status -- active, paused, or retired.
One glance tells me which products are alive and which are flatlined.
Product Detail Pages
Clicking into a product opens a detail view with everything I need:
- Metrics summary -- visitors, revenue, and subscribers with period-over-period comparison
- Traffic chart -- daily pageviews and unique visitors over time
- Revenue chart -- gross revenue, refunds, and net revenue
- Product details -- domain, launch date, status, links to the live site and external dashboards
The charts use Recharts with responsive containers. On desktop, they render at 300px height. On mobile, they shrink to 200px. This sounds like a small detail, but charts that overflow on mobile screens are a common dashboard antipattern:
<ResponsiveContainer
width="100%"
height="100%"
className="!h-[200px] sm:!h-[300px]"
>
<AreaChart data={chartData}>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="visitors"
stroke="#8884d8"
fill="#8884d8"
fillOpacity={0.3}
/>
</AreaChart>
</ResponsiveContainer>
Aggregate Analytics
Beyond individual product views, I built aggregate analytics pages that answer portfolio-level questions.
The aggregate traffic page shows a stacked area chart with all products layered on top of each other. This instantly reveals which products drive the most traffic and how the portfolio performs overall. Below the chart, a table breaks down today's live visitors per product alongside historical totals.
The revenue page aggregates Stripe data across all products, showing gross vs. net revenue over time with breakdowns by product.
The subscriber growth page tracks email list size across all Resend audiences, with new signups and unsubscribes visualized as separate series.
These aggregate views are the real value of a centralized dashboard. Any individual product's metrics are available in their respective platforms. But seeing all of them together, normalized and comparable -- that is something only a custom dashboard can give you.
Empty States Matter
Most of my products are young. Some have traffic but no revenue. Some have neither. A dashboard full of zeroes and blank charts is demoralizing and useless.
I spent time on empty states. When a product has no traffic data, instead of showing an empty chart, the card displays a helpful message with a link to configure the Umami integration. When there is no revenue, it suggests connecting Stripe. These CTAs turn dead space into actionable next steps.
It is a small thing, but it changes the emotional experience of using the dashboard. Instead of "nothing is working," it becomes "here is what to do next."
Making It Work on Mobile
I check my stats on my phone more than my laptop. Morning coffee, standing in line, between meetings. If the dashboard doesn't work on mobile, it doesn't work for me.
The mobile work touched almost every component:
Layout: I switched from h-screen to h-dvh for the app shell. The difference matters on mobile Safari, where the address bar eats into viewport height. dvh (dynamic viewport height) accounts for the browser chrome, so the layout never overflows or underflows.
Navigation: A slide-out mobile nav mirrors the desktop sidebar exactly -- same sections, same links, same hierarchy. I initially forgot the Analytics section in the mobile nav, which meant traffic and revenue pages were unreachable on phones. Caught that during testing.
Grid layouts: Product cards go from a multi-column grid on desktop to a single column on mobile. Metric summaries shift from a 4-column grid to a 2-column grid. The settings page product list wraps vertically instead of forcing horizontal scroll.
Container padding: The default 2rem container padding was too generous on small screens. I made it responsive -- 1rem on mobile, 1.5rem on tablets, 2rem on desktop.
// tailwind.config.ts
container: {
center: true,
padding: {
DEFAULT: '1rem',
sm: '1.5rem',
lg: '2rem',
},
},
Brand Integration
A dashboard of eight products needs visual identity. Each product card showing a generic icon makes it harder to scan. I integrated real product logos.
The logos live at public/brand/{slug}/logo.png -- convention over configuration. The ProductAvatar component tries to load the logo for the product's slug. If no logo exists (some products like Gitography don't have brand assets yet), it falls back to a colored circle with the product's initials:
function ProductAvatar({ name, slug }: { name: string; slug: string }) {
const logoPath = `/brand/${slug}/logo.png`;
return (
<div className="relative h-10 w-10 overflow-hidden rounded-lg">
<Image
src={logoPath}
alt={name}
fill
className="object-cover"
unoptimized // Required for Ideogram-generated logos
onError={(e) => {
// Fall back to initials
e.currentTarget.style.display = 'none';
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-muted text-sm font-medium">
{name.slice(0, 2).toUpperCase()}
</div>
</div>
);
}
The unoptimized prop on the Next.js Image component is a gotcha I discovered the hard way. Ideogram-generated logos have color profiles that Next.js image optimization mangles. Bypassing optimization preserves the original colors.
Settings and Product Management
The settings page lets me add, edit, and remove products without touching the database directly. Each product has fields for its name, slug, domain, Umami site ID, Stripe account ID, and Resend audience ID. Simple CRUD through API routes.
This might seem like over-engineering for a single-user dashboard, but it pays off when onboarding a new product. Launch a new site, add it to the dashboard through the settings page, and it immediately appears in the grid and starts collecting metrics on the next sync cycle.
Last Synced: Derived, Not Stored
I wanted a "last synced" timestamp on the dashboard so I know the data is fresh. My first instinct was to add a sync_log table. Then I realized: the daily_metrics table already has created_at timestamps. The most recent created_at across all rows is the last sync time. No extra table needed:
SELECT MAX(created_at) as last_synced FROM daily_metrics;
A small example of a recurring principle: before adding infrastructure, check if the data you need already exists somewhere.
Why Every Indie Hacker Needs This
If you're running more than three products, you need a command center. Not because any individual dashboard is insufficient, but because the cognitive overhead of context-switching between them is a hidden tax on your decision-making.
The Helsky Labs Dashboard answers three questions in under 10 seconds:
- What's working? Sort by traffic or revenue. The winners float to the top.
- What needs attention? Empty states and flatlined sparklines highlight neglected products.
- What's the trend? Aggregate charts show whether the portfolio is growing or stagnating.
These are the questions that determine where I spend my next sprint. Without the dashboard, answering them took 15 minutes and multiple tabs. With it, one glance.
What's Next
The MVP is running and I use it daily. But the roadmap includes:
- WhatsApp/Discord alerts -- notifications when a product hits a milestone (first 100 visitors, first sale)
- Goal tracking -- set targets like "reach $1k MRR" and track progress visually
- CSV export -- for deeper analysis in spreadsheets or sharing with an accountant
- Weekly email digest -- a summary sent via Resend so I don't even need to open the dashboard
The beauty of building your own tools is that they evolve with your needs. As the portfolio grows from 8 products to 15 to 25, the dashboard scales with it. The architecture supports it -- one more row in the products table, one more card on the grid.
Build Your Own
If you're an indie hacker juggling multiple products, I'd strongly encourage building something like this. It doesn't need to be complex. A Next.js app, a Supabase database, and API calls to your analytics provider. You can have a working version in a weekend.
The hard part isn't the code. The hard part is committing to looking at your numbers every day and making decisions based on what they tell you. The dashboard just removes the friction that makes it easy to avoid.
Building in public at Helsky Labs. Find me on GitHub and Twitter/X.