Case Study

Waypoints: the client portal I built instead of renting one

A login-gated quote and build-progress portal, built into this site. Clients read their quote, ask for changes, accept with a click-wrap agreement, and then watch the build move through sea trials to launch, live. You are reading a case study about the system that would onboard you (which is either very meta or very efficient, and I am comfortable with both).

The Problem

Quotes die in inboxes

The standard freelance flow is a PDF quote attached to an email, followed by silence, followed by a scattered thread of “can we change this one thing,” followed by (if you are lucky) a “sounds good!” that would not hold up as an agreement anywhere. And after the yes, the client is flying blind: they have no way to see where their build stands short of asking, and asking feels like nagging, so they either nag or they quietly wonder.

The off-the-shelf answer is three subscriptions: a proposal tool, an e-sign tool, and something for status updates, each renting you a slice of the workflow on somebody else's domain, in somebody else's branding, with your client's data in somebody else's database. I wanted one place, on my domain, in my design, where a quote is a living page instead of an attachment: readable, revisable, acceptable in the legal sense of the word, and then useful for the whole life of the project instead of just the sales part. So I built it.

The Client Side

What a client actually sees

Everything below is the real system with a demo client in it: M/V Knot Today Charters, a fictional business named after a very real boat (ours, and she has never chartered a day in her life; she does sunsets and fishing).

Waypoints sign-in, dark water panel
01

One link, no passwords

Clients sign in with a magic link sent to the email their quote lives under. No password to invent, no account to "create," and nothing to forget: the email address is the identity, and the database checks it on every single row (more on that below). The sign-in panel is the one dark moment in the flow, my own water shot with the ink overlay, because you should know whose dock you just stepped onto.

Quote view: scope, add-ons, investment box
02

The quote, readable by humans

Included scope with status chips, add-ons with prices, and one investment box with the deposit and balance spelled out. Included features say "included," never "$0" (a zero next to a feature reads like it might cost something later, and that is not the feeling I am going for). Terms sit right on the page, and there is a print stylesheet for clients who like paper.

Revision request card: "Want changes first?"
03

Want changes first?

Nobody should have to accept a quote they want one tweak on, and nobody should have to compose a Very Professional Email™ to ask. A textarea and a button: the request lands in my inbox with reply-to set to the client, gets logged against the quote, and the conversation just continues where it started.

Click-wrap acceptance: checkbox gating the Accept button
04

Acceptance that means something

A checkbox gates the Accept button (click-wrap, the same pattern every terms-of-service you have ever agreed to uses). The moment a client accepts, the system freezes the entire quote, every line item, every price, the full terms text, into an immutable snapshot, then emails a receipt to both of us with a one-page agreement PDF attached. "Wait, what did I agree to?" has exactly one answer, forever, and both sides are holding a copy of it.

Generated agreement PDF, line-itemed with brand mark
05

The agreement writes itself

The PDF is generated at acceptance time, line-itemed from the snapshot (base package, add-ons, percentage lines including discounts), with the brand mark where a letterhead would put it. No template to fill in, no document to remember to send: accepting the quote produces the paperwork as a side effect.

Deposit checkout: Stripe hosted, bank transfer offered first
06

Pay without leaving the dock

Accepting surfaces a Pay Deposit button on the same page, no invoice to dig out of an inbox. It is Stripe hosted checkout with bank transfer offered first, and the moment the payment settles the page knows: the deposit line flips to paid in real time, same as every other status here, and a receipt lands in both inboxes. Nobody sends the "did you get my payment?" email, because the page already answered it.

Build progress tracker: on the hard, sea trials, launched
07

On the hard, sea trials, launched

After acceptance the same page becomes a live build tracker. Every piece of scope carries a status in the house vocabulary: on the hard (not started), sea trials (in progress), launched (done and live). When I update a status in my builder, the client page updates in real time, no refresh. The whole point: my clients never have to send the "any update?" email, because the answer is always one bookmark away.

Client checklist ("What Bec needs from you") with the mockup card
08

The homework has a home too

I have watched plenty of redesigns stall. Never once on the code. They stall on homework: domain access, hosting logins, the photos, the release from whoever built the last site. So the portal says it plainly, in a checklist the client ticks off themselves; I see the checks land in real time, and the build stops living at the mercy of a buried email thread. The mockup card sits just above it: one link that follows the build from first mockup to staging to launched site.

Ship's Log message board with the running project thread
09

The Ship's Log

Projects generate conversation, and email scatters it. So every project carries a running log: my client and I post to the same thread, on the page, next to the work, and it updates live like everything else here. It is append-only on purpose (same philosophy as the acceptance snapshot: the record is the record), writes go through the same named-door pattern with the author verified server-side, and notification emails are nudges only, no content, so the thread in the portal stays the one true copy. And when a checklist item has somebody stuck, this is where "stuck" gets said, one scroll away from the list that raised it.

Under the Hood

The part for the technical readers

Fair warning: this half of the page is for the people who hire engineers. If you are here to get a website built, everything you need to know is above this line (TL;DR: the boring, load-bearing parts of your project get treated like they are load-bearing).

Next.js App RouterTypeScriptSupabasePostgres RLSSupabase Auth (magic link)Supabase RealtimeResendpdf-libStripeVercel

The database is the security boundary

Postgres row-level security, not API middleware, decides who sees what. Client policies only ever match rows where the signed-in email equals the email on the quote; owner policies are separate. An API bug cannot leak what the database refuses to serve, which is the difference between "we check permissions" and "permissions are structural."

Writes only happen through named doors

There are no generic write endpoints. Every mutation a client can cause (request a revision, accept a quote) is a SECURITY DEFINER Postgres function with a pinned search path, granted to authenticated users only, that re-verifies the caller’s email against the quote and checks the quote is in a state where that action makes sense. The function is the contract.

Acceptance is an immutable snapshot

Accepting does not set a boolean. It serializes the full quote (items, prices, terms text) into a snapshot record keyed to the quote, appends to an event log, and only then flips the status. Quotes are editable right up until they are not, and the snapshot is what both parties are holding when it matters.

The browser never gets a vote on money

Every total, deposit, and balance is computed server-side from the catalog: flat tier base, add-ons, percentage lines applied to the pre-rush subtotal. What the client sees is a rendering of the math, never an input to it. This matters even more with payments in the mix: an amount that arrives from a browser is a suggestion, not a number.

Email is part of the product, not a notification

Transactional email runs through Resend’s REST API directly (no SDK dependency, and it handles the base64 PDF attachment fine). Invites, revision requests, and acceptance receipts are all branded templates, and even the auth emails go through custom SMTP so the magic link a client receives looks like it came from my practice, because it did.

Realtime because silence is a support ticket

The client page subscribes to its own quote over Supabase realtime. Status chip changes and phase updates push to the open page. It is a small thing that removes a whole category of email, and it makes the portal feel alive instead of like a report that happens to be on a website.

Debugging Story

The three-letter bug

During testing, magic links kept dumping signed-in users onto the homepage with an unused auth code sitting in the URL, which is the auth equivalent of being handed a key that opens nothing. The flow crosses three systems (the email template, the auth provider's redirect allowlist, and my callback route), so there were plenty of places to look, and the actual culprit was hiding in none of them.

The root cause was a chain: the hosting config was silently redirecting the apex domain to www (backwards from what anyone intended, and invisible because Chrome hides the www), www was not on the auth allowlist, so the redirect fell back to the site URL, and even when the page did load, the PKCE verifier cookie had been set on the other origin, so the code exchange failed anyway. Three separate systems, all misbehaving in the same direction, all because of three letters.

The fix: canonicalize the domain first (apex serves the site, www permanently redirects to apex), then align the auth site URL and allowlist to the one true origin. The lesson I keep: when auth redirects misbehave, audit your origins before you audit your code. Mixed www and apex origins silently break anything that is per-origin, which is cookies, which is PKCE, which is your login.

Payments

The money joined the portal

Payments run through the portal end to end: deposit on acceptance, balance before launch, and care plans as proper subscriptions, all through Stripe hosted checkout with bank transfer offered first. Stripe won on arithmetic, not vibes: ACH at 0.8% capped at five dollars versus the card-rate alternatives, which at real project prices is the difference between a rounding error and a car payment. Acceptance and payment stay separate steps on purpose, and the amounts are, say it with me, always computed server-side.