How This Page Was Built
This document walks through everything it took to go from "create a page about my sister" to a live, password-protected, database-backed feature in production — dev environment, libraries, database changes, GitHub workflow, and deployment. No credentials, connection strings, or user IDs are included anywhere below.
It's written as a real build log, not a tutorial — the steps happened in this order, including the mistakes and fixes.
Starting point
This was added to an existing Next.js 16 (App Router) project already running in production on a Hostinger VPS via Docker + Traefik, with:
- A working CI-free deploy pipeline (
deploy.sh) - An existing Postgres database (
bourneforgeai) on a shared VPS Postgres instance, already used for one feature (anonymous page-like counts) - A local Docker Compose file for a disposable dev Postgres, from that same earlier feature
Nothing here required setting up a new hosting account, a new database server, or a new CI system — it built on infrastructure that already existed.
1. The static page (no new dependencies)
The first version was plain Next.js:
src/app/sister/page.tsx— a new App Router route, using existing shared components (SiteHeader,SiteFooter,PublishedDate) and existing Tailwind design tokens already defined inglobals.css- Photos supplied as image files, saved to
public/sister/1.jpgetc. (static assets, no upload pipeline needed) - Rendered with
next/image(already part of Next.js — no install needed), usingobject-fit: coverand a per-photoobject-positiontuned by eye so crops stayed centred on people instead of sky/background src/app/menu/page.tsxandsrc/app/experiments/page.tsx— plain listing pages, same pattern
Libraries installed for this step: none. Everything used was already a
project dependency (next, react) or built into the framework.
2. Password-gated caption editing (this is where the database work starts)
The ask: let captions be edited from the live page itself, behind a shared password, with changes persisted permanently.
2a. Local database setup
The project already had a disposable dev Postgres defined in
docker-compose.dev.yml (from an earlier feature), so no new tooling was
needed — just:
docker compose -f docker-compose.dev.yml up -d
This starts a local postgres:16-alpine container, isolated from production.
2b. Schema change — new migration file
The project doesn't use a migration framework (Prisma, Drizzle, etc.) —
migrations are plain, hand-written .sql files under migrations/, applied
manually. Added:
-- migrations/0002_photo_captions.sql
CREATE TABLE IF NOT EXISTS photo_captions (
photo_id TEXT PRIMARY KEY,
caption TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Applied locally via:
docker exec -i <dev-postgres-container> psql -U <dev-db-user> -d bourneforgeai \
< migrations/0002_photo_captions.sql
2c. Backend API route
src/app/api/captions/[photoId]/route.ts — a Next.js Route Handler with:
GET— public, returns the current caption/location for a photo IDPUT— requires a password (compared server-side usingcrypto.timingSafeEqualon SHA-256 hashes, so it isn't vulnerable to a naive string-length timing attack), rate-limited per IP (in-memory token bucket), validates the photo ID against an explicit allowlist (src/lib/editable-photo-ids.ts) so the endpoint can't write arbitrary rows
This reused the existing pg npm package (node-postgres) — already a
dependency from the earlier likes feature, so again nothing new installed.
2d. Frontend
src/components/editable-photo-gallery.tsx — a client component
("use client") that:
- Fetches the current caption/location for each photo on mount
- Shows an "Edit captions" toggle; once a password is entered, inline text inputs replace the captions, with a Save button per photo
- Sends the password with each save request; the server is the only thing that ever validates it (the client just relays what the user typed)
2e. New environment variable
Added CAPTION_EDIT_PASSWORD to:
.env.local(local dev only, git-ignored) — for testing against the local dev database- The VPS's existing
.envfile (git-ignored, never committed) — appended alongside the existingDATABASE_URLline, over SSH
2f. A bug caught during deployment
The first production deploy came back with "Editing not configured" even
after the password was in the VPS .env. Cause: docker-compose.prod.yml
only explicitly forwarded DATABASE_URL into the container's environment:
block — new variables in the host's .env file aren't automatically visible
inside the container unless the compose file says so. Fixed with a one-line
addition:
environment:
DATABASE_URL: ${DATABASE_URL}
CAPTION_EDIT_PASSWORD: ${CAPTION_EDIT_PASSWORD} # added
Rebuilt and redeployed — this is the kind of thing that only surfaces once you actually deploy, which is why "verify in production after every deploy" mattered here.
3. Adding a location field (second schema change)
Reused the exact same pattern — one more migration, one more column, no new tooling:
-- migrations/0003_photo_captions_location.sql
ALTER TABLE photo_captions ADD COLUMN IF NOT EXISTS location TEXT;
Applied locally, then later the same command applied to production Postgres over SSH once the code was ready to ship.
The location renders as a link to Google Maps
(https://www.google.com/maps/search/?api=1&query=<encoded place>) — plain
URL construction, no Maps API key or SDK needed since it's just a search-link,
not an embedded map.
GPS from the photos themselves: checked first, before assuming manual
entry was necessary. Used Python + Pillow (already installed on the dev
machine, not added for this project) to inspect each photo's EXIF GPSInfo
tag and to search the raw file bytes for an XMP packet. Every photo came back
with no location data embedded in the file — Google Photos can show a
location in its own UI (pulled from its own database) that doesn't
necessarily travel with an exported/downloaded copy of the file. Since
extraction wasn't possible, the location field became a manual, editable
field using the infrastructure already built for captions, rather than an
automated extraction feature.
4. Lightbox (click a photo to see it full-size)
Client-side only — a modal overlay added to the same
editable-photo-gallery.tsx component:
useStatefor which photo index is opennext/imageagain, this time withobject-fit: containso nothing gets cropped- Keyboard handling (Escape to close, arrow keys to navigate) via a
useEffectthat adds/removes akeydownlistener only while open - Backdrop click and a visible "Close" button, both dismiss it
No new dependencies — this is standard React state + the DOM APIs already available in the browser.
5. Verification at every step
Before anything was committed, each change went through:
npm run lint # eslint
npx tsc --noEmit # TypeScript type-check, no build output
Then a local functional check — either curl against the dev server's API
routes directly, or driving an actual browser against
http://localhost:<dev-port> to click through the real UI and read back the
rendered DOM/page text.
6. Git / GitHub workflow
Every change went through the same cycle — never straight to main:
git checkout -b <descriptive-branch-name>
git add <files>
git commit -m "..."
git push -u origin <branch-name>
gh pr create --title "..." --body "..."
gh pr merge <number> --merge --delete-branch
git checkout main
git pull
(gh — the GitHub CLI — was already installed and authenticated; not
something set up as part of this feature.)
Merging to main on GitHub does not deploy anything by itself — it just
updates the repository. Production stays on whatever it was last deployed
from until a deploy is explicitly run.
7. Deploying to production
The project deploys to a self-managed Hostinger VPS running Docker, not a
platform like Vercel. ./deploy.sh (already existed before this feature)
does the following, unattended:
- Aborts if there are uncommitted local changes
git push origin main- SSHes into the VPS and, inside the project directory:
git pull origin maindocker compose -f docker-compose.prod.yml up -d --build
- Polls the container until Docker reports it healthy
Two things had to happen manually, outside deploy.sh, because the project
has no migration-runner:
- Database migrations — each new
.sqlfile was piped over SSH straight intopsqlrunning inside the production Postgres container, e.g.:ssh -i <key> <user>@<host> \ "docker exec -i <prod-postgres-container> psql -U <db-user> -d bourneforgeai" \ < migrations/000N_something.sql - New environment variables — appended to the VPS's
.envfile over SSH before the next deploy, so the running container would pick them up.
After every deploy: curl checks against the live URLs (the page itself, and
the new API route) confirmed things actually worked in production, not just
in the build log.
8. What was already there vs. what got added
| Category | Already existed | Added for this feature |
|---|---|---|
| Hosting/runtime | VPS, Docker, Traefik, Next.js app | — |
| Database server | Shared Postgres instance, bourneforgeai database | — |
| Database client library | pg npm package | — |
| Local dev database | docker-compose.dev.yml | — |
| Deploy pipeline | deploy.sh | — |
| GitHub CLI | gh, authenticated | — |
| Database tables | page_likes | photo_captions (+ location column) |
| API routes | /api/likes/[slug] | /api/captions/[photoId], /api/photo-gps/[photoId], /api/generate-caption/[photoId] |
| Env vars | DATABASE_URL | CAPTION_EDIT_PASSWORD, ANTHROPIC_API_KEY |
| Pages | (starter template only) | /sister, /sister/process, /menu, /experiments |
| npm packages | next, react, pg | exifr (GPS/EXIF/XMP parsing), @anthropic-ai/sdk (vision captioning) |
The core editable-caption feature (sections 1–6 above) needed zero new npm packages, hosting accounts, database servers, or CI/CD systems — it ran entirely on infrastructure that already existed. The two enhancements below (GPS extraction, AI-generated captions) did each pull in one small, purpose-built npm package, since neither capability exists anywhere in Next.js/React itself.
9. Things that went wrong (and how they were caught)
| Issue | How it was caught | Fix |
|---|---|---|
docker-compose.prod.yml didn't forward the new env var to the container | curl-testing the live API right after deploy returned "Editing not configured" | Added the variable to the compose file's environment: block, rebuilt |
| Transient SSH timeouts to the VPS | ssh connection attempts timed out for a few minutes while the live site itself stayed up | Confirmed the site was still healthy via curl (ruled out a real outage), waited, retried — resolved on its own |
| Assumed a photo had no location data before checking XMP, not just EXIF | User pointed out Google Photos showed a location for a photo that had been reported as having none | Re-checked with both EXIF and XMP parsing; adopted "always check both" as a standing rule going forward |
Same docker-compose.prod.yml env-forwarding gap hit again, this time for ANTHROPIC_API_KEY | Known from the first time it happened — checked and added it proactively before deploying, rather than waiting for it to fail live | Added ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} alongside the other two env vars in the same environment: block |
Caption generation returned a generic "Caption generation failed" with no way to tell why | Isolated the Anthropic SDK call outside Next.js, in a throwaway Node script, to see the real error | The real cause was a billing issue (see below), but the generic error hid it — rewrote the route's catch block to pass through the SDK's actual APIError message instead of swallowing it |
| Caption generation failed even with a valid, correctly-wired API key | The extracted error message (see above) said outright: "Your credit balance is too low to access the Anthropic API" | Not a code bug — the Anthropic account itself needed credits purchased. Confirmed fixed by re-running the exact same request once credits were added |
10. Adding GPS extraction and an AI-generated caption suggestion
Two more editor controls, added after the core feature had been live for a while, each behind the same password gate as caption editing:
-
📍 Read GPS from photo —
src/app/api/photo-gps/[photoId]/route.ts, a public (no password needed — it only reads what's already in a publicly served image)GETroute that reads the photo file straight off disk and checks it for embedded location data. Usesexifr(npm install exifr) — the first genuinely new npm package this project needed — to check EXIF GPS tags first, then XMP as a fallback, per the "always check both" lesson from section 3. Returns latitude/longitude if found; the editor fills the location field automatically. For every photo on this page, both come back empty — confirming what the earlier manual EXIF/XMP check found — so this is an honest "reads if present" feature, not a guarantee every photo will have a location to extract. -
✨ Suggest a caption with Claude —
src/app/api/generate-caption/[photoId]/route.ts, a password-gated, rate-limited (5 requests/minute/IP — tighter than the caption-save limit, since each call is a real, billed API request)POSTroute. Reads the photo file, base64-encodes it, and sends it to Claude (modelclaude-sonnet-5) as an image content block with a one-line prompt asking for a short, warm caption. Uses@anthropic-ai/sdk(npm install @anthropic-ai/sdk) — the second new package. The suggestion fills the caption draft only; nothing is saved until the user reviews it and clicks the existing Save button, so a bad or off-tone suggestion never reaches the live page unedited.
New environment variable: ANTHROPIC_API_KEY, added the same way
CAPTION_EDIT_PASSWORD was — .env.local for dev, the VPS .env for
production, plus a line in docker-compose.prod.yml's environment: block
(and yes, that env-forwarding gap from section 2f was hit again here before
being caught proactively — see the table above).
Both routes share one small refactor: src/lib/editable-photo-ids.ts went
from a plain allowlist of IDs to a map of photoId -> file path, so all
three photo-related routes (captions, GPS, caption-generation) resolve a
photo's file the same validated way instead of each guessing a path from
the ID.
11. Routine maintenance: fixing an npm audit finding
Separately from feature work, npm audit flagged 4 high-severity CVEs —
nanoid, postcss, and sharp, all pulled in transitively through
Next.js itself rather than anything this project depends on directly.
npm audit fix alone didn't touch them; fixing them meant bumping
next from 16.2.12 to 16.3.0, one minor version outside the range
npm audit fix will change without --force.
This one didn't get fixed inline — it was flagged as a follow-up (a
background task chip, in this case, since the tooling supports handing off
scoped, well-defined follow-ups like this one) rather than folded into
whatever feature was being built at the time, since a framework version
bump deserves its own focused verification pass rather than riding along
with an unrelated change. That pass covered: reading Next.js's own
version-16 upgrade notes for anything between .2 and .3 that could
affect this app, then npm run build, npm run lint, and a dev smoke test
of the gallery, projects, notes pages, and the API routes — before going
through the same branch → PR → merge flow as everything else. npm audit
now reports zero vulnerabilities.
Published