Client Hosting Portal
A private, password-protected portal for freelance hosting clients. Live service status, Stripe billing management, and a terminal-style authentication flow built entirely on Next.js 16 and Vercel.
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
— Status dashboard
The live component from the portal itself. Each row expands to show a sparkline of recent checks. Uptime bars show the last 30 days — hover a bar for the exact date and percentage. Stats tick up on scroll using a custom easing counter.
— Your hosting
Live status of the servers running your sites.
- 01Web hosting90d uptimeOperational— —Operational— —
- 02Database cluster90d uptimeOperational— —Operational— —
- 03DNS & SSL90d uptimeOperational— —Operational— —
- 04Mail relay90d uptimeOperational— —Operational— —
- 05Daily backupsLast 02:14 ago—Last 02:14 ago—
— Metrics & monitoring
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
A terminal-style verification sequence replaces the form while the password is checked. Steps enter one by one before the sequence starts — so you know what's coming. Each step spins, then resolves. The failure state marks the exact step that failed.
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
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.
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.
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.
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.
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.
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 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.
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 intercepts all requests matching /hosting/:path*. Requests to/hosting/login are passed through unconditionally; everything else requires the auth cookie to match the env-var token. No token or wrong token redirects to the login page.
The cookie is set with httpOnly: true, secure: true, path: "/hosting", and a 30-day maxAge. The path scoping means the browser never sends the cookie to the public-facing routes — it only exists for /hosting requests.
Animation architecture
The login animation runs in two phases managed by separate state variables. Phase one (introRevealed) controls which step rows are visible — they slide in using staggered timeouts before the check sequence begins. Phase two (revealed and done) drives the running → done transitions per step.
A ref (revealedRef) shadows the revealed state to avoid stale closures in the error-handling effect. When the server action returns an error, the effect reads revealedRef.current to find the last step that started — and marks exactly that step as failed. All pending timers are cancelled via a tracked array of timer IDs cleared on each submission.
Uptime bar component
Each service row renders 30 bars representing the last 30 days. Each bar height scales proportionally — a full-height bar is 100% uptime, a short bar is a partial-day incident. Bars animate in with a scaleY keyframe staggered by index, triggered by an IntersectionObserver on the section. Hover states show a tooltip with the exact date and uptime percentage. The detail drawer animates open using CSS grid-template-rows: 0fr → 1fr — no JS height measurement needed.