01Plates

Survey plates

Captures from the live marketing site and the public roadmap. The board itself sits behind a sign-in.

The DashSuey home page: the headline over a working board with live clock, date and weather widgets beside the add-a-widget tray
Plate 01 · Home page · a board you can try before signing in
The DashSuey widgets page: the All 38 widgets heading over live Clock, World Clock and Year Dots tiles
Plate 02 · Widget index · all 38, some of them running
The DashSuey World Clock page: the headline beside the running widget and its place, format and seconds controls
Plate 03 · Widget page · the real widget, with its settings
The DashSuey public roadmap: category filters over four columns, Under consideration, Planned, Building and Shipped, each card showing its vote count
Plate 04 · Public roadmap · votes set the order

02Survey brief

Why a personal dashboard got a real database

DashSuey began as my own dashboard: weather, todos, reminders, GitHub activity, Spotify and site health on a grid I could drag into any shape. One user, no login, no public URL.

The cheap way to build that is a static page and localStorage. I went the other way on purpose: a hosted database from the first commit, every widget a self-contained component, and the multi-user steps written down before anyone else could sign in. I was betting that if the personal version was engineered like a product, the product would be an upgrade instead of a rewrite.

That's roughly how it went. Going multi-user meant adding auth, adding a user_id column and turning on row-level security, and the migrations still show it in order: first any signed-in user, then the owner only, then every person seeing only their own rows. The existing widget, settings, todo and reminder tables gained a column and a policy.

03The instruments

38 widgets, and the registry became data

Early on, each widget file called registerWidget() at module scope and an index file imported every one of them. That worked while the list was short. It also meant every board downloaded the code for every widget, even a board showing three.

Now the catalogue is plain data. WIDGET_META holds each widget's label, group, sizes and default settings, and a second map holds one dynamic import() per widget. Both are typed against the same widget union, so a widget missing from either map fails the build instead of failing in someone's browser. Loading widget code on demand cut a small board's first load by about a fifth.

The 38 pull from wherever their data lives: MET Norway for weather and forecasts, OpenWeather's air pollution feed with the US AQI worked out in-house from the EPA breakpoints, GitHub's APIs for contributions and trending repos, Spotify for what's playing, and plain math in the browser for sunrise and sunset.

Data specimen · one widget, as data
// lib/widget-meta.ts
'time': {
  type: 'time',
  label: 'Clock',
  category: 'Time',
  defaultSize: { w: 3, h: 3 },
  minSize: { w: 2, h: 2 },
  defaultConfig: { ... },
},

// components/widgets/loaders.ts
'time': () => import('./TimeWidget'),
  • Clock
  • Date
  • World Clock
  • Count Down
  • Count Up
  • Weather
  • Forecast
  • Air Quality
  • Sunrise & Sunset
  • Moon Phase
  • Hacker News
  • News Feed
  • Wikipedia
  • Daily Quote
  • Word of the Day
  • Daily Prompt
  • Weird Fact of the Day
  • Lighthouse
  • Core Web Vitals
  • Search Console
  • GitHub Contributions
  • GitHub Trending
  • Claude Usage
  • Your IP Address
  • Todo List
  • Reminders
  • Sticky Note
  • Simple Table
  • Search
  • Unit Converter
  • Visited States
  • Year Dots
  • Life Dots
  • Currency Rates
  • Spotify
  • Album Wall
  • Image Channel
  • Social accounts

Survey noteA new widget is still one component file, plus one line in each map. The type checker won't let either line go missing.

04Grid 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 is worked out from the window width so cells stay square, with an 88px floor and a 1280px minimum grid width so a small window can't crush the widgets. Type inside each widget scales with the cell.

  2. First-fit placement

    Adding a widget calls findOpenPosition(), a first-fit scan for the first open slot that now steps around widget groups too. Or skip the scan and drag a widget from the picker straight onto the spot you want.

  3. Every drag writes home

    onLayoutChange saves each widget's position and size as it happens. If the database refuses a write, the widget goes back to its saved spot, so the screen never shows a layout the server doesn't have. There's no save button.

Grid math · Dashboard.tsx
// square cells at any window width
// COLS 12 · MARGIN 14 · PADDING 20
const gridWidth = Math.max(MIN_GRID_WIDTH, containerWidth)
const rowHeight = Math.max(
  MIN_ROW_HEIGHT,
  Math.round((gridWidth - PADDING * 2 - MARGIN * (COLS - 1)) / COLS),
)

05Base layers

Accounts, and a hard line between two database clients

Sign-in is Supabase Auth with Google. Every page is private by default: a Next.js proxy sends signed-out visitors to the login page and answers API calls with a 401, apart from a short list of public paths like the roadmap and the weather forecast. A database trigger gives each new account its member record and a first board.

Every table that holds someone's data carries a user_id, and row-level security is on for all 56 tables. Data still moves through client components and route handlers, 90 of them now, and there still isn't a server action in the repo.

Client · anon key

The browser talks to the database as the person signed in

  • Todos, reminders and layout read and write in place.
  • Row-level security decides what every query can see.
  • Roadmap votes go straight to Postgres, and a trigger enforces the budget.

Server · service role

The privileged client never leaves the server

  • A server-only module, reached through an actor whose permissions are checked first.
  • Third-party keys stay server-side; the browser never sees them.
  • OAuth callbacks for Google, GitHub, Spotify and the social platforms land here.

06Product layer

Plans, boards, and a roadmap people vote on

  • Plans live in the database

    Free and Pro limits are rows the admin console edits, not constants in the code. A widget over the Free cap gets locked, never deleted.

  • Boards and widget groups

    Pro gets up to five boards, and groups that trace a named, colored outline around their widgets and fold down to a single strip.

  • Starter boards

    A new account can start from a ready-made board (At a glance, Working day, Developer, or Something to read) instead of an empty grid.

  • A public roadmap with a vote budget

    Anyone can read it; voting takes an account. Each plan gets a fixed number of votes to spread around, and they come back once an item is decided.

  • Stripe without the SDK

    Checkout, the billing portal and the webhook are plain fetch calls. The webhook checks signatures in constant time, rejects anything over five minutes old, and records event ids so a redelivery can't apply twice.

  • Your data, downloadable

    One request returns everything a person owns, with credentials stripped out, including the ones tucked inside widget settings.

07Fine grain

Details for running it with other people on it

  • Rate limits, learned the hard way

    A burst of about 94,000 requests over two days used 75% of the month's Netlify function allowance. Now the proxy limits each visitor before a page renders: 30 requests a minute signed out, 300 signed in.

  • An admin console with an audit log

    23 admin screens, from people and invitations to plans, billing and running costs. Every change checks a named permission first and writes an audit row after.

  • View as

    The owner can see the app as a role, a plan, or one specific person. It's read-only and audited, it expires after 30 minutes, and any credentials on that board are swapped for placeholders.

  • Checks that drive the real thing

    17 verify scripts run against real accounts. One signs in as two people and proves through the database's own API that neither can read the other's rows. Another tests a genuine Stripe signature against seven tampered ones.

  • Four themes, no flash

    Space, Dark, Light and CRT. A small script in the head reads the saved theme and sets it on the html element before first paint, so the page never flashes the wrong colors.

  • A marketing site that runs the real widgets

    dashsuey.com is a separate Eleventy site. Its tiles are ports of the app's own widget code, an edge function tells the weather tile roughly where the visitor is, and 33 widget pages each run their widget live.

08Margin 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.

  • Invite only, for now

    Signup runs through a waiting list. Opening it is a one-line change on the marketing site, and every page there was built to look finished either way.

  • Still no test runner or CI

    The verify scripts hit real accounts and real flows, but nothing runs them on every push. Netlify builds both sites, and a CI pipeline is still an unchecked box on the TODO.

  • Billing hasn't met real money

    Stripe is wired end to end, but the only subscription the database has ever held was made with a test card.

Stack

  • Next.js 16 · App Router
  • React 19
  • TypeScript
  • Tailwind CSS v4
  • Supabase Auth + Postgres
  • react-grid-layout v2
  • Stripe
  • Resend
  • Eleventy 3
  • Netlify

By the numbers

  • 38 widgets in six groups
  • 90 API route handlers
  • 56 tables, all under row-level security
  • 67 SQL migrations
  • 4 themes, flash-free

09Next traverse

Have an internal tool that wants to be a product?

DashSuey is what that jump looks like when it's planned from the first commit. The data layer was real from day one, so going multi-user was mostly a matter of adding things. If your team has a tool inching toward becoming a product, I help teams in Chicago and everywhere make that move on purpose, with the frontend development I take on for clients.