Back to About

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

The real components from the hosting page. Each site is checked from the server, with results shared between visitors for 30 seconds, and your browser pings each site every 10 seconds while you watch — the chart and the bars are your own round trips.
All systems normal

Round trip from your browser to each site, live

Pinging from your browser…
  • 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 200
    • Photo 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

Live checks answer “is it up right now?”. Uptime history needs somewhere to keep results — this is the planned design.

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.

ColumnTypeNotes
iduuidPrimary key, gen_random_uuid()
service_idtextReferences services table
checked_attimestamptzIndexed for range queries
statustextoperational | degraded | down
response_msintegernull if service unreachable
errortextnull if healthy
Supabase schema for the service_checks table.

The login experience (first version)

A terminal-style verification sequence replaced the form while the password was checked. Steps entered one by one before the sequence started — so you knew what was coming. Each step spun, then resolved. The failure state marked the exact step that failed.

Mid-verification

— RECEIVE_REQUESTOK
— PARSE_CREDENTIALSOK
— VERIFY_PASSWORD⠸
— OPEN_SESSION

Wrong password

— RECEIVE_REQUESTOK
— PARSE_CREDENTIALSOK
— VERIFY_PASSWORDFAILED
— OPEN_SESSION

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

From the password-protected first version. Open any decision to see the reasoning.
  • 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

0

emails answering ‘is my site down’

30s

server checks, shared by every visitor

10s

browser pings while you watch

0

passwords needed to see status

Need a site that does more than sit there?

Tell me a little about your club, business or idea. I'll reply personally — no sales team, no forms, just a conversation about what you need.

Email me
Alexander Dean, web developer in the UK. It's --:-- here.