Bourne Forge AI
← Karen

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 in globals.css
  • Photos supplied as image files, saved to public/sister/1.jpg etc. (static assets, no upload pipeline needed)
  • Rendered with next/image (already part of Next.js — no install needed), using object-fit: cover and a per-photo object-position tuned by eye so crops stayed centred on people instead of sky/background
  • src/app/menu/page.tsx and src/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 ID
  • PUT — requires a password (compared server-side using crypto.timingSafeEqual on 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 .env file (git-ignored, never committed) — appended alongside the existing DATABASE_URL line, 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:

  • useState for which photo index is open
  • next/image again, this time with object-fit: contain so nothing gets cropped
  • Keyboard handling (Escape to close, arrow keys to navigate) via a useEffect that adds/removes a keydown listener 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:

  1. Aborts if there are uncommitted local changes
  2. git push origin main
  3. SSHes into the VPS and, inside the project directory:
    • git pull origin main
    • docker compose -f docker-compose.prod.yml up -d --build
  4. 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 .sql file was piped over SSH straight into psql running 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 .env file 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

CategoryAlready existedAdded for this feature
Hosting/runtimeVPS, Docker, Traefik, Next.js app
Database serverShared Postgres instance, bourneforgeai database
Database client librarypg npm package
Local dev databasedocker-compose.dev.yml
Deploy pipelinedeploy.sh
GitHub CLIgh, authenticated
Database tablespage_likesphoto_captions (+ location column)
API routes/api/likes/[slug]/api/captions/[photoId], /api/photo-gps/[photoId], /api/generate-caption/[photoId]
Env varsDATABASE_URLCAPTION_EDIT_PASSWORD, ANTHROPIC_API_KEY
Pages(starter template only)/sister, /sister/process, /menu, /experiments
npm packagesnext, react, pgexifr (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)

IssueHow it was caughtFix
docker-compose.prod.yml didn't forward the new env var to the containercurl-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 VPSssh connection attempts timed out for a few minutes while the live site itself stayed upConfirmed 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 EXIFUser pointed out Google Photos showed a location for a photo that had been reported as having noneRe-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_KEYKnown from the first time it happened — checked and added it proactively before deploying, rather than waiting for it to fail liveAdded 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 whyIsolated the Anthropic SDK call outside Next.js, in a throwaway Node script, to see the real errorThe 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 keyThe 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 photosrc/app/api/photo-gps/[photoId]/route.ts, a public (no password needed — it only reads what's already in a publicly served image) GET route that reads the photo file straight off disk and checks it for embedded location data. Uses exifr (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 Claudesrc/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) POST route. Reads the photo file, base64-encodes it, and sends it to Claude (model claude-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