Visit

Meracious

A custom-printing store built as a reusable e-commerce template — every piece of brand-specific content lifted out into config files, so the next store is a copy and five edits rather than a rebuild.

  • Role: Solo — frontend, data model, admin, checkout, custom-design pipeline, template architecture, deployment.
  • Stack: Next.js 16, React 19, TypeScript, Firebase (Firestore, Storage, Auth), TanStack Query, Zustand, React Three Fiber, Resend, Tailwind.
  • Status: Deployed at meracious.shop. The owner is still populating the catalogue — not yet trading. Unpaid build.

The problem

Meracious sells custom-printed products, which makes it a harder e-commerce problem than it looks. A normal store sells a thing that already exists. A printing store sells a thing that doesn't exist yet, and the customer has to hand over the artwork that brings it into being.

That means the checkout can't just collect money and an address. It has to collect files — and it has to deliver them to whoever is actually running the press in a form they can work from.

The second problem was mine rather than the client's: I didn't want to build this store and then build the next one from scratch.


What I built

A storefront with per-variant inventory. Products carry stock per variant rather than a single count, with low-stock and out-of-stock alerting, plus promo codes and transactional email.

A custom-design upload pipeline. Customers attach artwork to their order; the shop receives it as a single named archive.

A brand configuration system. All brand-specific content — identity, contact details, every string of site copy, SEO metadata, checkout content — lives in /brand as typed config, separate from application logic.


Deep dive: getting a printable file out of a customer

The flow has three stages, and the middle one is the interesting part.

While the customer shops, their artwork stays on their own machine. Uploaded designs go into IndexedDB — a meracious_designs store holding the file bytes as an ArrayBuffer, its MIME type, size, a base64 preview for rendering thumbnails, and the design field it belongs to. Nothing has touched a server yet.

At order placement, the designs are zipped and uploaded together. One archive per order, named for the order ID, pushed to Firebase Storage.

The archive is named for humans, not for machines. This is the detail worth keeping. Each file inside the zip is named from the design field the customer uploaded it against — sanitised for the filesystem, deduplicated with a numeric suffix if two fields share a label, capped at 50 characters, falling back to design_1, design_2 only when there's no field name to use.

The difference that makes is the difference between a printer opening order_a83kd.zip and finding design_1.jpg, design_2.jpg and having to email someone about which goes where, versus opening it and finding files named after the thing they're printed on. Same bytes; only one of them is usable without a conversation.

Validation happens before any of this: images only, 5MB per file, 5 files per upload, checked client-side at selection time so a customer finds out immediately rather than at checkout.

On the record: holding the files locally until order placement wasn't a response to a storage bill or a measured abandoned-cart problem. It was the arrangement that seemed logical — don't ship a stranger's files to a server until they've committed to an order. The effects are real regardless: abandoned carts cost nothing in storage, and a customer can browse away and come back with their artwork still attached.


Deep dive: a free storage tier as a design constraint

Firebase's free storage tier is finite, and a store that accepts 5MB image uploads will find its edges quickly.

Rather than let that fail at an unpredictable moment, the constraint is handled explicitly in code. MAX_ORDERS_WITH_DESIGNS = 40: before every upload, the service lists the designs folder, reads each file's creation time from its metadata, and if the count has reached the ceiling it deletes the oldest archives to make room.

Two things make this defensible rather than alarming:

Forty orders is real runway for this business. The archive only needs to survive from order placement to fulfilment. Once a job is printed, its source files have done their work — the order record in Firestore persists regardless, and it's the order, not the artwork, that the store needs to remember.

Deletion failures don't take the order down. Each delete is wrapped individually and logged, and the cleanup pass runs to completion even if a file resists removal. The worst case is that the folder sits one file over its ceiling, not that a paying customer's checkout throws.

The honest framing is that this is a rotating window, not an archive — and it's the first thing that would change if the store outgrew the free tier.


Deep dive: the store that's meant to be copied

The thing I'd point at first in this repo isn't a feature of the store. It's that the store is a template.

Every piece of brand-specific content lives in /brand as typed configuration, split by concern: identity, contact details, all site copy, SEO metadata, checkout content, theme documentation — each with a committed .example.ts alongside it as a reference for the next build. Application code in /app, /components and /lib holds business logic and imports content; it never hardcodes a brand string.

The test of a separation like this is whether it holds under pressure, and the place it usually breaks is the awkward content. Here the features section carries its own icon imports from @tabler/icons-react inside the config, rather than the component picking an icon from a string key — so adding a feature to a new store is a config edit, not a config edit plus a lookup-table edit in a component.

The result is a documented path: copy the project, update five config files, change the CSS variables in globals.css, swap the logo and favicon, deploy. The architecture is documented in the repo itself rather than living in my head — BRAND_ARCHITECTURE.md, BRAND_SYSTEM_SUMMARY.md and brand/README.md.

This was the intent from the start, not a pattern noticed afterwards, and the template is in active use as the base for further store builds.


Smaller decisions worth naming

Inventory is per-variant, with normalisation. Stock lives as a map of variant key to quantity, with a base key for products that have no variants. There's a normalisation pass that repairs legacy __base__ keys and discards non-finite quantities, because inventory maps accumulate junk over a schema's life and the alternative is arithmetic on NaN. Low-stock thresholds are per-product with a default of 5.

Designs are served through a proxy. /api/design-proxy takes a base64-encoded Firebase Storage URL, validates it really is a Firebase Storage address, fetches the file server-side and streams it back — so a customer's artwork isn't handed out as a public storage URL. A small measure, but custom artwork can be someone's own intellectual property and a guessable link is a poor place to keep it.

A 3D hero. React Three Fiber renders a notebook model on the landing page — the one place in the build where live 3D earns the runtime cost, because the product category is physical objects.

Transactional email has a fallback. Resend for sending, Nodemailer behind it.


Results

Deployed and handed over. The owner is working through catalogue setup — uploading products and data — so the store hasn't taken real orders yet, and there are no sales figures to report.

What is finished and verifiable: the storefront, per-variant inventory with alerting, promo codes, the custom-design pipeline end to end, the admin area, and the brand configuration system — which is already doing its second job as the base template for further store builds.

Built solo, unpaid.