Building a Self-Hosted Catalog Viewer to Escape a SaaS Price Trap
Overview
The trigger was simple: a vendor renewal notice. A SaaS platform we were using to host interactive PDF catalogs for a client — covering three distinct lines of business — came back at renewal time with a proposal to double the annual cost and require a 12-month commitment across all three. That combination, a 100% price increase tied to a year-long lock-in, made it worth asking whether the platform was doing anything we couldn’t do ourselves.
The answer was no. The core capability is a PDF viewer that renders catalog pages in a two-up spread, lets users navigate and download, and produces clean shareable links. None of that requires a third-party platform. We built a replacement over a weekend using Claude Code and Cowork, deployed it on Cloudflare infrastructure we already operate, and had our first catalog live at flip.goosedigital.ai before the renewal deadline.
Approach
We started by defining what the viewer actually needed to do, stripping away everything the old platform offered that nobody was using. The requirements came down to four things: two-page spread rendering, deep-linkable pages, per-client branding, and a clean URL structure that matched how catalogs were already organized by brand and season.
The viewer itself is a single self-contained HTML file built on PDF.js. All CSS is inline, all JS is inline — no build tools, no framework, no dependency management. The design mirrors the clients’ existing brand contexts via URL parameters: ?pdf=, brand=, title=, website=, and favicon= are resolved at request time and injected into the viewer. A client’s favicon appears in the toolbar; their brand name links back to their site. The viewer handles retina/HiDPI rendering, zoom, keyboard navigation, jump-to-page, a copy-link button, and a download button.
PDF storage lives in Cloudflare R2 at cdn.flip.goosedigital.ai. Routing is handled by a Cloudflare Pages Function rather than a static redirects file — the function reads a catalog-index.json config from R2 on each request, resolves the brand and catalog slug to the correct PDF URL and metadata, and serves the viewer with params injected. This means adding or swapping a catalog requires no git push and no redeployment. The only time the codebase needs to change is when the viewer itself changes.
Catalog operations — adding a new PDF, swapping an updated file at an existing URL, listing active catalogs, deactivating old ones — are handled by a local shell script (catalog-manager.sh) that wraps the Wrangler CLI. The script is interactive, prompts for the relevant fields, uploads the PDF to R2, updates catalog-index.json, and prints the live URL. A cache purge fires automatically on swap if the Cloudflare Zone ID and API token are set as environment variables.
We built the whole thing inside Cowork using Claude Code: the Pages Function, the viewer HTML, the not-found page, the catalog index page, the CORS headers config, the shell script, and the GA4 event instrumentation.
Results
The viewer is live and serving catalogs for multiple brands across the client’s three lines of business. The URL structure is clean: flip.goosedigital.ai/{brand}/{season}. Deep links work via URL hash (#page=14) and are preserved through the routing function. The copy-link button surfaces the hash-based URL for sales and marketing teams to share specific pages directly.
The total cost of the infrastructure is a fraction of the vendor renewal. Cloudflare Pages and R2 costs at this scale are negligible. There is no per-seat pricing, no per-catalog pricing, and no renewal negotiation.
Operationally, adding a new catalog takes about five minutes: compress the PDF, run ./catalog-manager.sh add, answer the prompts, and the link is live. No ticket, no platform UI, no waiting on a third party.
GA4 is instrumented with three custom events — catalog_view, page_turn, and catalog_download — each carrying brand and catalog title as dimensions. This gives us better analytics data than the vendor platform was providing, and it feeds directly into the client’s existing GA4 property rather than a separate dashboard.
Lessons Learned
-
Vendor lock-in is a forcing function. We had no reason to question the old platform until the renewal arrived. The 100% price increase and 12-month commitment changed the calculus instantly. Sometimes the right moment to evaluate a dependency is when the cost of staying becomes concrete.
-
The viable scope is often much smaller than the product you’re replacing. The SaaS platform had features we had never configured and the client had never asked for. Stripping the requirement down to what was actually used made the build tractable in a weekend. Scope creep from unused vendor features is a hidden cost of SaaS dependency.
-
No-deploy catalog management was the right architectural choice. Storing routing config in R2 instead of a static redirects file meant the operational workflow — add, swap, deactivate — requires no developer involvement and no deployment pipeline. That separation keeps the infrastructure stable and the catalog operations fast.
-
Self-contained HTML files are underrated for tools like this. A viewer that is a single file with no external build dependencies will still work in three years. There is no Node version to manage, no packages to audit, no pipeline to maintain. For a tool that is fundamentally stable — rendering PDFs in a browser has not changed — this is the right default.
-
Claude Code and Cowork compressed the build significantly. The Pages Function, shell script, and supporting pages were written and iterated inside a single Cowork session. The architectural decisions were made in conversation; the implementation followed immediately. A project that might have taken several days of scoped development work landed in a weekend.