Snapshot
A B2C memory-book studio for Pixign (Ahmedabad). Photos in, print-ready PDFs out. The product itself is small - upload, an auto-layout engine, a mobile-friendly customiser, a PDF export - but every part of it is a recovery surface for things mobile browsers do badly.
Context
Pixign sells printed memory books and wanted a self-serve studio so customers could lay their own albums out without a designer in the loop. The old flow was email-attached photos and Photoshop. The new flow had to work on a phone, survive iOS Safari's memory limits, deduplicate photos across sessions, and end in a press-ready PDF.
Client work via Feyntech.AI. Shipped on Fly.io, Supabase for auth + storage + Postgres.
Problem
The hard parts weren't the layout engine or the PDF generation. They were everything around them:
- iOS Safari kills the tab when the photo set is large. Without intervention the user reloads into the upload modal and loses session context - even though the work is auto-saved server-side.
- HEIC files from iPhones don't decode in every browser. Tiled HEVC variants don't decode in any of them client-side.
- Customers re-upload the same photos across sessions; without dedupe, the storage bill compounds.
- A two-pass thumbnail strategy is needed because doing image work in API routes is expensive enough to be visible.
Approach
Tab-kill recovery. The studio writes a small heartbeat to sessionStorage every five seconds - {bookId, at}. On the next /create mount, if the heartbeat is fresh (under 60s), the page treats the load as a tab kill, re-fetches the book, and routes the user back into the studio on the book they were editing. Don't clear it on unmount; the time-staleness check is the only signal that distinguishes a clean reload from a kill.
HEIC fallback. Client tries heic2any first; on failure, a server route decodes through sharp. The fallback path catches the iPhone HEVC variants the client library can't.
Photo dedupe. Every photo is SHA-256'd client-side. The photos table is unique on (user_id, sha256). Re-uploads land on the existing row.
Two-pass thumbnails. The proxy at /api/books/photo?id=… resolves photos through a fast path (a 512px JPEG written client-side at upload time, stored alongside the original) and falls back to a sharp lazy-resize on miss, deduping concurrent first-fetches. As the fast-path drains over time, the lazy fallback becomes deletable.
AutoFlowEngine. A deterministic function that maps a photo set to spread templates (Full Page, Side by Side, Stacked, Big + Small, 4 Grid, 6 Grid, 9 Grid) based on count + orientation, with a prime-7 stride to scatter similar templates. Re-runs are idempotent for the same input; per-slot transforms are stored separately so user pans survive re-runs.
Tech
- Next.js 14 (App Router), React 18, TypeScript
- Supabase (Auth + Postgres + Storage), Drizzle ORM
- Custom hash-tracked migration runner - migrations are immutable once applied
pdf-lib+@pdf-lib/fontkit+@react-pdf/renderer+@resvg/resvg-jsfor the print pipelinesharpserver-side for image work- Fly.io standalone Docker, region
bom
What I learned
Most of the engineering in a "simple" creative tool is recovery. The layout engine is 143 lines; the tab-kill heartbeat, the upload queue's exponential backoff, the thumbnail proxy, and the migration runner are each about as long, and each one fixes a class of problem you don't see until users hit it on a phone in a bad-network coffee shop.
