← Back to work

Full-stack (solo build) · 2026

Fjordstay

A booking platform for premium Nordic holiday homes: a server-rendered guest and host app on a typed GraphQL API over PostgreSQL, with an agent that drafts listing copy and metrics that measure the product rather than the process.

Two simultaneous requests for the same nights end with one booking and one guest told the dates just went, enforced by the database rather than by the resolver. The whole guest flow works with JavaScript disabled.

Source ↗
Cover image for Fjordstay

The problem

A booking site looks like CRUD and is not. The moment two people want the same week, availability stops being a field you read and becomes a race you have to lose safely. Everything else on the surface — search, a calendar, a price — is the easy half.

I wanted a build where the interesting decisions were forced rather than invented: real dates with real timezone traps, real money that must not drift, and a write-heavy path where being wrong is visible to a guest. Holiday homes give you all three, plus a second user with an entirely different job to do.

Approach

Availability is derived, never stored. A night is free unless a host blackout or a live booking covers it, so there is one source of truth and "why is this date taken?" has an answer instead of a guess about which table drifted.

Every range in the system is half-open — [check-in, check-out) — which is what lets one guest arrive on the day another leaves. That single convention decides whether every changeover day in the calendar is bookable or silently lost, so it is asserted in both directions in the tests.

Dates are calendar dates, not instants. A guest arriving on the 4th arrives on the 4th whether the server runs in Copenhagen or a UTC container, so every date is UTC midnight and all arithmetic goes through one module. Money is integer øre end to end, formatted once on the server so every client agrees and no float ever reaches a total.

Architecture

Fjordstay infrastructure Guests and hosts reach a Next.js app serving GraphQL on port 3000. A one-shot migrate container applies migrations and seeds the database before the app is allowed to start. The app queries PostgreSQL 16, which holds the booking overlap exclusion constraint, on port 5433, and calls the Claude API to draft listing copy. Listing photographs come from the repo's own public directory by default; setting PHOTO_BASE_URL points them at an S3 bucket served by LocalStack on port 4566, which runs only under the infra compose profile. Prometheus on port 9090 scrapes the app's metrics endpoint, and Grafana on port 3001 reads Prometheus. Terraform sits outside the stack and provisions the database roles and the bucket policy. docker compose queries drafts listing copy photos, when set migrate + seed scrapes /api/metrics bucket + policy roles + grants Guest Host Next.js appGraphQL + metrics:3000 Claude APIlisting agent PostgreSQL 16exclusion constraint:5433 migrate (one-shot)the app waits for it S3 (LocalStack)profile: infra:4566 Prometheus:9090 Grafana:3001 Terraform
One Compose stack. A one-shot migrate container seeds the database before the app is allowed to start; the photo bucket is opt-in, behind a compose profile, and photographs come from the repo by default. Terraform provisions the database roles and the bucket from outside the stack.

And the path a booking actually takes, from the form to the row:

Fjordstay booking request, browser to database A guest submits the booking form in the browser. It posts to a Next.js server action, which validates the name and email with Zod and then makes an HTTP POST to the app's own GraphQL endpoint — a real boundary rather than an in-process call. Yoga applies a query depth limit and records metrics, a Pothos resolver calls the booking service, and the service reads the listing, blackouts and pricing rules through Prisma before calling the pure domain modules to validate the stay and price it. It then calls booking.create. PostgreSQL is the final arbiter: a GiST exclusion constraint over listing and date range rejects any overlapping stay, so a second simultaneous writer gets error 23P01, which the API turns into DATES_UNAVAILABLE and the message those nights have just gone. On success the row is committed as PENDING and the server action redirects the browser to the booking reference page. POST · server action HTTP POST — a real boundary validate + price reads listing, blackouts, rules booking.create 23P01 committed redirect → /bookings/FJ-7K2QD4 Browserrequest form · no JS required Server actionsubmitBookingRequestZod: name, email /api/graphql · Yogadepth limit · metrics Pothos resolverrequestBooking lib/services/bookings Prisma PostgreSQL 16EXCLUDE USING gistlistingId WITH = · daterange WITH && Booking rowstatus PENDING lib/domain — purevalidateStay · quoteStayimports no Prisma DATES_UNAVAILABLE“those nights havejust gone”
Every box is a real file. The browser never calls GraphQL — there is no client-side data fetching in the app at all, which is why the guest flow works with JavaScript off. The service reads, then writes; the gap between those two is the race, and the constraint is what closes it.

How I built it

The double-booking race is the part worth reading. Checking availability and then inserting is a read-then-write race that no amount of care in a resolver closes — under concurrency both requests read "free" before either writes. So the rule lives in Postgres as a GiST exclusion constraint over (listing, daterange) for live statuses, which makes overlapping bookings physically unrepresentable. The second insert fails, and the API turns that failure into a sentence a guest can act on. A test fires both requests concurrently and asserts exactly one booking exists.

The house style for listing copy is one file used twice: it goes into the model's prompt, and the same rules are then run against what comes back. Banned phrases, length, paragraph count — checked mechanically, with the failures shown to the host rather than swallowed. A style guide that only lives inside a prompt is one nobody can enforce.

Three bugs were only visible from outside the code. A root loading.tsx made every route stream, and once a response starts streaming Next cannot go back and set a 404 — so every not-found page was answering 200 until I checked the status code rather than the rendered page. The Vitest config was missing an alias for server-only, so the integration suite was collecting zero tests while reporting green; the fix was two lines, but the reason to look was a test count that had quietly dropped by ten. And the filters toggle rendered as an empty box for anyone without a CJK font, because the marker was a fullwidth plus (U+FF0B) rather than an ASCII one — which I only noticed because headless Chrome has no CJK font either, and it turned up in the screenshot I was taking for this page.

What I'd do differently

The response types the pages use are hand-written. The schema is generated from Prisma through Pothos, so a drift between them surfaces as a runtime null rather than a build error — the honest fix is codegen, and it is the first thing I would add.

Authentication is the deliberate gap. The host dashboard picks who it is acting as and stores the id in an httpOnly cookie; what matters is that the server never trusts a host id from the client, so the GraphQL context reads the cookie and every mutation re-checks ownership against it. Real auth replaces one helper, not the authorisation model. Payments are mocked entirely: a booking request is a request, and nothing is charged.