Back to Projects
Client Work

Catalog CMS

IN PRODUCTION SURFACE: DOCKER / DOKPLOY VPS

SvelteKit 2 app backed by SQLite, deployed in Docker on a Dokploy VPS, with async product/FAQ sync to a WordPress REST + ACF backend.

Products
~200
DB tables
15
Storage
SQLite + R2
Commits
111

The Problem

I was building a WordPress site for a B2B cleaning products company when I realized how they wanted to organize their catalog was going to be a constant maintenance problem. Product data — SDS PDFs, photos, ingredient disclosures, dilution ratios, certifications — all lived in spreadsheets, and any change meant an email to me. I built them a CMS so they could own their catalog and I could stop being the bottleneck.

There was also a modeling quirk baked into their business: products in their dispensing system share a product number across package sizes and categories. Standard product management tools model SKUs as unique identifiers. This one doesn’t.

Approach

I built a server-rendered SvelteKit 2 app with Svelte 5 runes — it’s a form-heavy internal tool, and runes are genuinely nicer than stores for that pattern. SQLite + Drizzle because there’s one deployment target, one writer at a time, and I didn’t want to manage a Postgres server for light internal traffic.

File storage is abstracted behind a getStorage() factory: LocalStorageProvider in dev, R2StorageProvider in prod gated on R2_ACCOUNT_ID — swapping environments without branching on NODE_ENV.

Auth is better-auth with email/password and password reset via Resend, rate limiting on the login endpoint, and an audit log on every write operation.

The Hard Part

The dispensing system quirk meant a single SKU maps to multiple database rows — one per (category, package size) combination. That’s fine for display, but it broke document management: SDS PDFs, literature sheets, and ingredient disclosures get bookmarked directly by end users. If replacing a document generates a new URL, every existing bookmark 404s.

The fix was to assign each item its own internal opaque ID — not the SKU, not surfaced anywhere in the UI — and key all R2 file paths on that ID. When a document is replaced, it writes to the same path. Bookmarks stay valid. The ID is meaningless to users and load-bearing to the storage layer.

WordPress Sync

Products and FAQs sync to a WordPress site with custom post types, ACF field groups, and several custom taxonomies (categories, industries, placements, certifications). I built an async queue (wp_sync_queue table) processed server-side on a periodic trigger — each item gets retries with exponential backoff, and a hash of the serialized payload prevents redundant syncs. FAQs support internal product links that resolve to WP permalinks at sync time. Dead-lettered items expose a reset-failed action so nothing blocks silently.

Current State

Running in production on Dokploy (Docker + Traefik), SQLite volume-mounted at /app/data. Core catalog CRUD is stable. The WP sync is functional — it went through several debugging rounds and now has dead-letter recovery in place.

Built with Claude Code as the primary implementation tool. I owned the architecture decisions, Dokploy/Docker deployment, and the WordPress site it integrates with.

Stack

SvelteKit 2 · Svelte 5 · SQLite · Drizzle · Cloudflare R2 · better-auth · Resend · Docker · Dokploy · Traefik · WordPress REST · ACF