← blog

 /  4 min read

Product catalog and admin panel for an electrical shop

No cart, no checkout, one WhatsApp button.

This is the website for an electrical and security-equipment shop in Çanakkale: switches, cable, cameras, intercoms, satellite gear. The first decision made everything simpler: it isn't a webshop. It's a live price-and-stock catalog, and every product page has a button that opens WhatsApp with the message already written. Customers still talk to a person, and the site makes sure they ask about the right thing.

export function waLink(number: string, message: string) { const digits = (number || "").replace(/[^\d]/g, ""); return `https://wa.me/${digits}?text=${encodeURIComponent(message)}`; }

The stack is TanStack Start (React 19), Tailwind v4 and Zod, deployed as a Cloudflare Worker with D1 (SQLite) for the data and R2 for the photos. There's a custom admin panel, so the owner never has to ask me to change a price.

7tables in D1
7admin tabs
5 MBupload cap
100,000PBKDF2 iterations
7 daysadmin session
0servers to keep alive
browserReact 19 + TailwindCloudflare WorkerTanStack Start, server fnsD1products, settings, stats, adminsR2product photos, logos, hero imagesGitHub Actions deploys on push to main
No server to run and no disk to back up: D1 and R2 hold all the state, and the Worker is redeployed from git.

Counting without inflating

The dashboard shows the most viewed products and the ones people ask about on WhatsApp. Counters like that are easy to inflate, so a small table has the primary key (product, kind, visitor fingerprint, day). Recording a stat just tries to insert that row, and if the insert fails, this visitor already counted today and nothing else happens. The fingerprint is a truncated SHA-256 of IP and user agent, so no raw IP address ever reaches the database.

// primary key = (product, kind, fingerprint, day) try { await db.run( "INSERT INTO product_stat_hits (product_id, kind, fingerprint) VALUES (?, ?, ?)", [data.productId, data.kind, await clientFingerprint()], ); } catch { return { counted: false }; // already counted today } await db.run(`UPDATE products SET ${column} = ${column} + 1 WHERE id = ?`, [data.productId]);

Discounts as an operation

The discount tab applies a percentage (1 to 99) to everything, one category or one brand. The new price is computed from the list price, rounded to kuruş and stored beside it, and the panel keeps a note of what it applied so it can be undone in one click.

const value = clear ? null : Math.round(p.price * (1 - pct / 100) * 100) / 100;

The storefront only shows a discount when the discounted price is really lower than the list price, so a bad value can't show up as a sale. And price_updated_at moves only when a price field changes, not on every edit, so the date on a product card tells a shopper how fresh the price is.

export function hasDiscount(p: Pick<Product, "price" | "discount_price">) { return p.discount_price != null && p.discount_price > 0 && p.discount_price < p.price; }

Photos that clean up after themselves

Uploads are admin-only, capped at 5 MB, and limited to an allowlist of image types. SVG is deliberately not on it: an SVG can carry a script, and opening the file's URL directly would run it, which is stored XSS. Files are also served with nosniff.

export const MIME: Record<string, string> = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif", ".ico": "image/x-icon", // no ".svg": it can embed <script> };

Everything lives in one R2 bucket, laid out so a file's key says what it is:

keywhat lives there
products/<slug>/<slug>.jpg, <slug>_2.jpgproduct photos, numbered from what's already in the folder
uploads/header-footer/logo-header.<ext>fixed slot, a new upload replaces it
uploads/main-page/hero-1.<ext>fixed slot, same rule
uploads/site-favicon.<ext>fixed slot, same rule
uploads/<uuid>.<ext>one-off images such as brand logos

New product photos are numbered from what's already in the folder, so an old file is never overwritten, even if one was deleted in between.

function nextProductFilename(existing: string[], slug: string, ext: string): string { const suffixPattern = new RegExp(`^${slug}_(\\d+)$`); let maxIndex = 0; for (const name of existing) { const base = name.replace(/\.[^./]+$/, ""); if (base === slug) maxIndex = Math.max(maxIndex, 1); else { const match = base.match(suffixPattern); if (match) maxIndex = Math.max(maxIndex, Number(match[1])); } } const nextIndex = maxIndex + 1; return nextIndex === 1 ? `${slug}${ext}` : `${slug}_${nextIndex}${ext}`; }

Removing a photo or deleting a product deletes the objects, so nothing is left orphaned. Fixed slots replace their previous file, and if the new extension differs, the stale one is removed too. The one exception is the header and footer logos: they can fall back to a shared file, so they are never deleted on clear.

Login without an auth service

Admin auth is one small file. Passwords are hashed with PBKDF2-SHA256 through Web Crypto, which Workers support natively, and sessions are a sealed cookie that expires after seven days. The iteration count is stored inside the hash string, so it can be raised later without breaking old passwords.

const hash = await pbkdf2(password, salt, ITERATIONS); return `${ITERATIONS}:${toHex(salt)}:${toHex(hash)}`; // iterations travel with the hash

There's no sign-up: admins are created by other admins, nobody can revoke their own access, and the very first admin comes from environment variables, only when the users table is empty. Secrets live in Wrangler secrets, not in the config. Every server function sits behind CSRF middleware, and the admin route is marked noindex.

Cloudflare Workers pitfalls

Three things cost me time. First, route files ship their top-level imports to the browser, so importing the database module at the top of a route pulls cloudflare:workers into the client bundle and breaks it. Every server function imports what it needs inside its handler.

export const someServerFn = createServerFn({ method: "POST" }).handler(async () => { const { getDb } = await import("@/lib/db.server"); // inside the handler, never at the top of the file const db = await getDb(); });

Second, D1's exec() splits statements on newlines, which breaks multi-line CREATE TABLE, so the schema is applied one statement at a time with prepare().run().

Third, when a server function throws, the framework swallows it into a generic 500 JSON body with no stack trace. A small wrapper around console.error remembers the real error, and the server entry recognizes the swallowed body, logs the full cause chain and serves a friendly HTML error page instead of raw JSON.

function isH3SwallowedErrorBody(body: string): boolean { const payload = JSON.parse(body) as { unhandled?: unknown; message?: unknown }; return payload.unhandled === true && payload.message === "HTTPError"; }

Smaller details

Filters and sorting live in the URL (category, brand, price range, in stock, discounted, sort) and are validated by the router, so any filtered view is a link you can send someone on WhatsApp. Search lowercases with the Turkish locale so İ and I behave, and slugs transliterate ç ğ ı ö ş ü. The sitemap is generated from the active products, and there's a security.txt.

A shop site is really two products: the one customers see, and the one the owner uses every day. The second one deserves as much design as the first.