01Survey brief

Why a personal dashboard gets a real database

This is my personal dashboard: weather, todos, reminders, GitHub activity, Spotify, site health, and the rest of a 25-widget grid I can drag into any shape I want. It has one user, no login, and no public URL.

The cheap way to build it is a static page and localStorage. I went the other way on purpose: a hosted database from the first commit, every widget built as a self-contained component, and a written roadmap that names the multi-user steps before they're needed. The bet is simple. If the personal version is engineered like a product, the product version becomes an upgrade instead of a rewrite.

The repo already talks like a product. The idea backlog tags certain widgets premium, and the TODO sketches accounts, saved dashboards, a marketplace, and a desktop wrapper. None of that is built yet, and this page won't pretend it is. What's built is everything below.

02The instruments

25 widgets, one registration pattern

Every widget is one file in components/widgets/. At module scope it calls registerWidget() and drops its metadata into a registry Map: default size, minimum size, accent color, whether it needs a location, whether it has settings. The dashboard shell never imports a widget by name. It reads the registry.

That keeps the cost of a new widget honest: one new file, plus one import line in the index. The import line matters more than it looks. It triggers the module-scope registration, and its position controls the widget's column when the grid rebuilds from defaults.

The registered 25 pull from wherever their data actually lives: OpenWeatherMap for the weather cluster, an RSS proxy for news feeds, GitHub's GraphQL API for the contributions graph, Spotify for now-playing with playback control, and plain client-side math for the moon phase.

Data specimen · a widget registering itself
// components/widgets/WeatherWidget.tsx
registerWidget({
  type: 'weather',
  defaultSize: { ... },
  minSize: { ... },
  accentColor: '...',
  locationBased: true,
  hasSettings: true
});
// + one import line in index.ts, and it exists
  • Time
  • Date
  • Weather
  • Forecast
  • Air Quality
  • Sunrise
  • Moon Phase
  • Quote
  • Word of the Day
  • Countdown
  • Count Up
  • Todo
  • Reminders
  • News Feed
  • Wikipedia
  • GitHub Trending
  • GitHub Contributions
  • Spotify
  • Search Console
  • Web Vitals
  • Lighthouse
  • Life Dots
  • Year Dots
  • Claude Usage
  • Hacker News

Survey noteEach of the 25 widgets is one self-contained file that registers itself and owns its own live data, defaults, and settings. The dashboard grows one file at a time, not through a tangle of shared wiring.

03Grid mechanics

The grid does the fussy work

react-grid-layout v2 draws the grid. The decisions around it are where the engineering lives.

  1. Square cells by arithmetic

    12 columns, 14px margins, 20px padding. Row height isn't a constant; it's computed from the window width so cells stay roughly square at any size. The whole rule is one line of math.

  2. First-fit placement

    Adding a widget calls findOpenPosition(), a small first-fit scan across rows and columns for the first open slot. No modal asking where to put it.

  3. Every drag writes home

    onLayoutChange persists each widget's position and size to the database as it happens. There's no save button. The layout you leave is the layout you get back, on any machine.

Grid math · Dashboard.tsx
// square-ish cells at any window width
// COLS 12 · margin 14 · padding 20
const rowHeight = Math.round(
  (containerWidth - 40 - 14 * 11) / 12
);

04Base layers

Two database clients, one hard line between them

Supabase holds the state in a handful of small tables: widget layout and config, settings, todos, reminders, quotes, and the statuses that tie the idea backlog to GitHub issues. Nothing clever. The point is that it's hosted and shared, not trapped in one browser profile.

The other route handlers do the outward-facing work: OAuth flows for Google and Spotify written as plain route handlers, an RSS proxy so feeds fetch server-side, and GitHub's GraphQL and REST APIs called with plain fetch. Data flow is client components plus route handlers, end to end. There isn't a server action in the repo.

Client · anon key

The browser talks to the database directly

  • Todos and reminders read and write in place.
  • Layout writes fire on every drag and resize.
  • Public anon key only, nothing privileged.

Server · service role

The privileged client never leaves the API routes

  • A separate server-only client lives in 5 of the 18 route handlers.
  • Third-party keys stay server-side; the browser never sees them.
  • OAuth callbacks for Google and Spotify land here too.

05Fine grain

Details built for the user who hasn't shown up yet

  • Per-widget accent system

    Each widget declares an accent color in its metadata. It flows into the DOM as CSS custom properties that the theme CSS picks up for glows, so a new theme restyles every widget without touching one.

  • Three themes, no flash

    Space, minimal dark, and minimal light, each a CSS file scoped to a class on the html element, with SSR fallback variables so the first paint never flashes the wrong theme.

  • A backlog wired to GitHub

    Around 500 widget ideas catalogued across 15 categories on a dedicated ideas page, tagged high-value, popular, gap, or premium, with each idea's status synced to GitHub issues through the app's own API.

  • Moon phase without an API

    Computed client-side from the date. No key, no request, no rate limit.

  • Air quality in six bands

    Live US AQI from Open-Meteo, with PM2.5, PM10, ozone, and nitrogen dioxide mapped across the six EPA bands from Good to Hazardous, each carrying its own color and health note. No key, no rate limit.

  • A site-health cluster

    Search Console, Web Vitals, and Lighthouse widgets fed through the Google OAuth flow, putting search and performance data on the same grid as everything else.

06Margin of error

Honest limits, written down

A case study about a work in progress should read like one. The margins are marked on the sheet.

  • One user, no auth

    There's no login and row-level security is off, and the project docs say so in plain words. The upgrade path is one sentence: add auth, add a user_id column, turn on RLS. That's an addition, not a rewrite.

  • No test suite yet

    Verification today is an ad-hoc Playwright script that captures screenshots for a visual check. CI/CD sits on the infrastructure list: planned, not pretended.

  • The product is sketched, not shipped

    The TODO reads like a pricing page in waiting: accounts, multiple saved dashboards, a widget marketplace, a Tauri desktop wrapper, and premium notes down to removing the logo and custom domains. All of it is still ink on the roadmap.

Stack

  • Next.js 16 · App Router
  • React 19
  • TypeScript
  • Tailwind CSS v4
  • Supabase
  • react-grid-layout v2
  • Netlify

By the numbers

  • 25 widgets live on the grid
  • 18 API route handlers
  • ~500 widget ideas catalogued
  • 3 themes, flash-free
  • 1 user, by design (today)

08Next traverse

Have an internal tool that wants to be a product?

This dashboard is how I build when the only stakeholder is me: a real data layer, docs that admit what's missing, and components that keep the next feature cheap. If your team has an internal tool inching toward becoming a real product, I help teams in Chicago and everywhere make that jump on purpose.