Osagie Media
A full-stack marketing site and admin panel for a UK wedding photographer, with a live booking-to-deposit flow and a DNS migration that turned into an upgrade.

Role
Sole Developer
Duration
4 weeks
Core stack
Next.js 16 · React 19 · TypeScript
Context
Overview
Osagie Media is a wedding photography studio. The brief was a public site that felt premium, an admin panel the photographer could actually use without calling me, and a booking flow that didn't ask for money before the photographer had confirmed they were available.
The Brief
Build the whole thing, deploy it, hand it over. The client owned a domain registered at GoDaddy, ran their email through Microsoft 365, and had no technical background. They wanted a booking system that collected a deposit — but only after they'd confirmed the date was free. And they wanted it live in four weeks.
Constraints
- Solo developer, four-week deadline, no team
- Zero hosting budget — the entire stack had to run on free tiers without violating terms of service
- Client is non-technical. Anything they can't edit through an admin panel is a support ticket six months later
- Booking flow cannot request a deposit until the photographer has explicitly confirmed availability
- The client's existing Microsoft 365 inbox cannot break during DNS work
01
Two rendering strategies in one app
The public site and the admin panel have opposite data requirements. Public pages are read-heavy, change rarely, and benefit from caching at the edge. The admin panel is write-heavy, single-user, and needs to see the truth the moment a mutation lands.
I started with React Server Components on both. On the public site that worked. On the admin panel it was wrong — every mutation required a full server round-trip and a router refresh, and the optimistic states felt sluggish on a slow connection. Mid-project I migrated the admin panel to TanStack Query while leaving the public site on RSC.
The split lives at one file: src/app/admin/(authed)/layout.tsx. Everything above that boundary is server-rendered. Everything below it is a client island that talks to Route Handlers over fetch.
The payoff was honest: the public site stayed fast, and the admin panel got the optimistic UI and cache management it needed. The cost was one migration I could have avoided by asking the right question at the start — 'who is this screen optimised for?' — before writing a single component.
02
Post-confirmation deposit flow with HMAC tokens
The client's requirement was specific: the client submits a booking enquiry, the photographer reviews it, and only after they confirm the date is free does the client get asked for a deposit. That meant the deposit request needed to be a state transition, not a form field.
I modelled the booking lifecycle as a state machine: PENDING → AWAITING_DEPOSIT → CONFIRMED → COMPLETED, with DECLINED and CANCELLED as terminal off-ramps. The state machine is the deposit tracker — no parallel depositStatus field, no risk of the two falling out of sync.
When the admin confirms availability, they get a modal with the deposit amount prefilled from the package's category. They can override it — repeat clients, corporate discounts, negotiated rates. On submit, the backend writes the amount and percentage to the booking (snapshot, not recompute), signs an HMAC token, and emails the client a link.
The token format is {bookingId}.{expiryUnix}.{hmac}, base64url-encoded. There's no token table — no rows to clean up, no expiry sweeps. Revocation is handled by state: once the booking leaves AWAITING_DEPOSIT, the token stops working because the verification step rejects it.
// src/lib/bookings/booking-token.ts (excerpt)
export function verifyBookingToken(token: string) {
const decoded = Buffer.from(token, "base64url").toString("utf-8");
const [bookingId, expiryStr, providedHmac] = decoded.split(".");
const expiry = Number(expiryStr);
if (!bookingId || !expiry || !providedHmac) return null;
if (Date.now() / 1000 > expiry) return null;
const expectedHmac = createHmac("sha256", env.BOOKING_TOKEN_SECRET)
.update(`${bookingId}.${expiry}`)
.digest("hex");
const a = Buffer.from(providedHmac);
const b = Buffer.from(expectedHmac);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
return { bookingId, expiry };
}03
Images never touch the deployment platform
The deployment ran on Netlify's free tier. Bandwidth credits would be consumed by every image request the site served. A photography portfolio would blow through them in a week.
The solution was to point every image URL directly at Cloudflare R2 through a custom Next.js image loader. The loader maps the requested width to one of three pre-generated variants — thumb, medium, large — and returns the R2 URL. Netlify never sees an image request.
Processing happens in the browser before upload. The admin selects a file, the canvas API resizes and converts it to WebP, and the processed file goes straight to R2 through a presigned PUT. The server never touches image bytes. R2 never stores originals.
// src/lib/r2-image-loader.ts
import type { ImageLoaderProps } from "next/image";
export default function r2Loader({ src, width }: ImageLoaderProps) {
const variant = width <= 600 ? "thumb" : width <= 1400 ? "medium" : "large";
return src.replace(".webp", `-${variant}.webp`);
}04
The DNS migration that wasn't supposed to be one
The domain was registered at GoDaddy. Email ran through Microsoft 365, resold through GoDaddy. DNS was managed at GoDaddy. The plan was to add five records — an A record, a www CNAME, and three DKIM/SPF records for Resend — and be done in two hours.
GoDaddy's delegate session broke. The login flow redirected into a mandatory phone verification interstitial, which failed six times before locking the verification method for 24 hours. The client's own login worked, but routing them through manual DNS edits on a live production zone was a bad idea — one mistyped DKIM value would silently break their email.
I migrated the zone to Cloudflare instead. Cloudflare's import tool scanned the existing zone and pulled all 17 records automatically, including the long Microsoft selector1 and selector2 DKIM CNAMEs. What had been a blocker became an upgrade: Cloudflare's DNS panel doesn't lock you out, propagates faster, and supports R2 custom domains natively — which the original plan had deferred for lack of a Cloudflare zone.
The migration took a day. The client's inbox never broke. And the deferred R2 custom domain and cache rules became possible immediately after.
Impact
Delivery
4 weeks
Kickoff to live production
PageSpeed (mobile)
100 / 96 / 100 / 100
Perf / A11y / BP / SEO
Security checklist
21 / 21
All items verified with evidence
Year-one cost
$0
Excluding client-owned domain
Stack
Framework
- Next.js 16
- React 19
- TypeScript
Data
- Prisma 7
- Neon Postgres
- TanStack Query
Storage
- Cloudflare R2
- Custom image loader
- Resend
- React Email
Infra
- Vercel
- Cloudflare DNS
- Upstash Redis
Auth
- Argon2id
- Opaque session tokens
- HMAC deposit tokens
What I'd do differently
If I did this again, I'd split the RSC decision earlier. The admin panel migration cost a few days mid-project that I could have avoided by asking who the screen was optimised for before writing the first component. The rest held up: the token-based deposit flow, the client-side image pipeline, and the DNS migration all worked because they were designed to fail safely — rate limiter fails open, deposit tokens fail closed, images never touch a service that charges by the byte.
Next case study
Pastura
Church operations platform • In progress