2025, personal project
Client Hosting Portal
A portal for freelance hosting clients: live service status, Stripe billing management and, in its first version, a terminal-style password login. Built entirely on Next.js 16 and Vercel.
- Next.js 16
- TypeScript
- Tailwind CSS v4
- Stripe
Update, October 2026: the status page is public
With two client sites live, the more useful thing was letting anyone — committee members, staff, prospective clients — see real status without a password. The checks are real now too: each site is probed from the server and pinged from the visitor's own browser. The password gate below has been retired, but the decisions behind it are kept here.
The problem
Freelance clients reliably ask two questions: “is my site down?” and “when does my hosting renew?”. Answering both required an email thread, a manual uptime check, and a reply. For a small number of clients this was manageable; as the practice grows it becomes noise.
The goal was a single branded space where clients can check service health, manage billing, and get in touch — without needing to contact me for any of it. Private by necessity (not every client needs to know which services are running), so it needed a clean auth layer rather than relying on obscurity.
My role
Solo build. Designed the auth architecture, built the status dashboard component, wired up Stripe Customer Portal, and spent more time than strictly necessary on the login experience — because the first impression of a client portal matters, and most clients will only see that screen once.
Architecture
Frontend
- Framework
- Next.js 16 · TypeScript · App Router
- Styling
- Tailwind CSS v4 · Custom CSS animations
- Fonts
- Fraunces · Figtree · IBM Plex Mono
- Components
- React Server Components + client islands
Auth (first version)
- Gate
- Next.js proxy — guards /hosting/:path*
- Session
- httpOnly cookie · 64-char random token
- Secrets
- Password + token stored as env vars
- Login UX
- Terminal-style verification animation
Billing & Status
- Billing
- Stripe Customer Portal (hosted)
- Payments
- Stripe Checkout → /payment-success
- Status
- Live checks · server and browser
- Hosting
- Vercel · global edge network
The live status board
Round trip from your browser to each site, live
- Bells Angels—
- Whitehouse—
- ClubHive—
- Sites online
- 0/3
- Checked from my server
- Server response
- 0 ms
- Average time to first byte
- From you
- —
- Average round trip, right now
- Services healthy
- 0/4
- APIs and storage behind the sites
- On TLS 1.3
- 0/3
- The newest encryption
- Certificates
- 0 days
- Until the soonest one expires; all renew automatically
- Server response
- 158 ms
- Domain lookup
- 23 ms
- Full download
- 164 ms
- Page weight
- 68 KB
- Protocol
- HTTP/2
- Encryption
- TLS 1.3
- Edge cache
- stale
- Served by
- Vercel
Security
- Plain HTTP redirects to HTTPS308
- Browsers told to always use HTTPSHSTS 2 yr
- Certificate from Let's Encrypt43 days
Services behind the site
ClubHive feed
Rides, news and photos come from this API, via Vercel
366 msOK 200
- Server response
- 236 ms
- Domain lookup
- 106 ms
- Full download
- 247 ms
- Page weight
- 1151 KB
- Protocol
- HTTP/2
- Encryption
- TLS 1.3
- Edge cache
- stale
- Served by
- Vercel
Security
- Plain HTTP redirects to HTTPS308
- Browsers told to always use HTTPSHSTS 2 yr
- Certificate from Let's Encrypt69 days
Services behind the site
Booking calendar
Live availability and prices from Hospitable, via Vercel
2405 msOK 200Photo storage
Property photos on Cloudflare R2, via cloudflare
290 msOK 200
- Server response
- 332 ms
- Domain lookup
- 110 ms
- Full download
- 334 ms
- Page weight
- 106 KB
- Protocol
- HTTP/2
- Encryption
- TLS 1.3
- Edge cache
- hit
- Served by
- Vercel
Security
- Plain HTTP redirects to HTTPS308
- Browsers told to always use HTTPSHSTS 2 yr, preload
- Certificate from Let's Encrypt51 days
- MIME sniffing blockedset
- Referrer policyset
- Permissions policyset
Services behind the site
Public API
Reads a club from Postgres, via Vercel
351 msOK 200
How the checks work
From the server
A single function checks every client site in parallel: a DNS lookup timed with dns.lookup, a TLS handshake that reads the peer certificate's issuer and expiry, and an HTTP request timed to the moment headers arrive. Anything 5xx or unreachable is down; anything slower than 1.5 seconds is slow.
The result is wrapped in unstable_cache with a 30-second revalidation, and the /api/status route adds a short CDN cache on top. However many people have the page open, the client sites see at most a couple of requests a minute.
From your browser
The server can't tell you how fast a site feels from where you are, so the page also pings each site's robots.txt with a no-corsfetch. The response is opaque, but the round trip can still be timed. The first ping pays for DNS and the TLS handshake, so it's thrown away, and pings pause while the tab is hidden.
Next: history with Supabase
The Supabase backend
A scheduled edge function polls each service endpoint every 60 seconds and writes a row into the service_checks table in Supabase — timestamp, service id, response time, and a status enum.
The dashboard queries the last 30 days of checks per service on each page load — a simple aggregation that produces uptime percentage, p50/p95 response times, and the 30-bar history. No caching layer needed at this scale; Postgres handles it in single-digit milliseconds.
Backups and real-time
The backup script logs file size, duration, and timestamp to Supabase after each run. The dashboard reads the most recent row for backup-specific stats — last size, duration, retention window, and time since last run.
Supabase real-time subscriptions push status changes to connected clients without polling — a NOTIFY on the service_checkstable triggers a subscription event and the dashboard updates live. A client watching during an incident sees the “Operational” indicator flip back the moment the service recovers.
| Column | Type | Notes |
|---|---|---|
| id | uuid | Primary key, gen_random_uuid() |
| service_id | text | References services table |
| checked_at | timestamptz | Indexed for range queries |
| status | text | operational | degraded | down |
| response_ms | integer | null if service unreachable |
| error | text | null if healthy |
The login experience (first version)
Mid-verification
Wrong password
Each step maps to what the server action does. RECEIVE_REQUEST is the HTTP round-trip; PARSE_CREDENTIALS is formData.get("password"); VERIFY_PASSWORD is the env-var comparison; OPEN_SESSION sets the httpOnly cookie.
Key decisions
The situation
A cookie set to a predictable value like 'authenticated' can be trivially forged by anyone who knows the cookie name and path — a common mistake in simple password gates. Replay attacks and session forgery become trivial.
What I did
The cookie value is a 64-char random hex token generated once and stored as an env var. The proxy validates the cookie value against the env var on every request — guessing the token is computationally infeasible. Rotating it (changing the env var) immediately invalidates all active sessions globally.
The situation
Next.js 16 deprecated the middleware.ts file and renamed the exported function. Code written against earlier conventions silently fails to intercept requests — there is no error, requests just pass through unguarded.
What I did
The request intercept layer lives in proxy.ts with an exported proxy function. The matcher config and cookie inspection logic are identical to what middleware.ts would contain — only the filename and export name changed. Heeding the deprecation notice meant the auth gate worked first time.
The situation
Stripe redirects to the success URL immediately after a completed checkout. The redirect is a plain GET from Stripe's servers with no auth context. If /payment-success were under /hosting/:path*, every successful payment would land on the login screen before the client could see confirmation.
What I did
Moved /payment-success to the app root — outside the /hosting/:path* proxy matcher. Clients reach it unauthenticated directly from Stripe. A clear 'back to portal' link takes them into the auth flow once they want to return.
The situation
The wrong-password server action adds a deliberate delay before returning. Without careful timing, the error could arrive while the animation is still on an earlier step — the FAILED indicator would land on the wrong row, breaking the illusion.
What I did
VERIFY_PASSWORD spins between 3450ms and 4290ms. Wrong-password delay is set to 3850ms — mid-spin for that exact step. When the server responds, the animation marks that specific step FAILED and the remaining steps stay dim. Correct password delay (6000ms) lands cleanly after all steps complete at 5590ms.
Technical depth
The proxy layer
Next.js 16 renamed middleware.ts to proxy.ts with a matching function export rename. The proxy intercepted all requests matching /hosting/:path*. Requests to /hosting/login were passed through unconditionally; everything else required the auth cookie to match the env-var token. No token or wrong token redirected to the login page.
The cookie was set with httpOnly: true, secure: true, path: "/hosting", and a 30-day maxAge. The path scoping meant the browser never sent the cookie to the public-facing routes — it only existed for /hosting requests.
Animation architecture
The login animation ran in two phases managed by separate state variables. Phase one (introRevealed) controlled which step rows were visible — they slid in using staggered timeouts before the check sequence began. Phase two (revealed and done) drove the running → done transitions per step.
A ref (revealedRef) shadowed the revealed state to avoid stale closures in the error-handling effect. When the server action returned an error, the effect read revealedRef.current to find the last step that started — and marked exactly that step as failed. All pending timers were cancelled via a tracked array of timer IDs cleared on each submission.
Outcomes
emails answering ‘is my site down’
server checks, shared by every visitor
browser pings while you watch
passwords needed to see status