3-app print-on-demand stack
Built a solo - api (Express + Bun), web (Next.js 16), admin (Next.js 16) - all on one Hostinger plan.
Online custom printing platform with ordering, tracking, and delivery for businesses.
Built a solo - api (Express + Bun), web (Next.js 16), admin (Next.js 16) - all on one Hostinger plan.
mid-flight from PhonePe with idempotent two-phase order creation - no double-charges, no orphan orders.
that survives client tampering - server re-derives effectivePageCount every pricing checkpoint.
survives login redirect - base64 / blob / metadata fallbacks, `QuotaExceededError`-resilient.
Closed the Hostinger trailing-slash redirect loop with one line. Hours of pain, documented forever.
Pagz is a print-on-demand store. A customer picks a category - say Booklet Print, uploads a PDF, picks paper size, color mode, sides (one or two), copies, addons (binding, lamination), sees a live price built from a category-specific rule engine, pays with Razorpay, and gets the printed booklets shipped.
I built the whole thing. Three Node.js apps, ~300 commits, four months, all deployed on Hostinger. No Vercel, no AWS - the entire stack lives on one Hostinger plan because that is what the client already had.
Three apps, three roles:
I wanted a Turborepo monorepo at the start. Shared types between web and admin, one CI run, one cache, one place to bump dependencies. Clean.
Then I learned the constraint - the client had a Hostinger shared hosting plan, not a VPS. Shared hosting means no Docker, no PM2 across services, no monorepo build pipeline. You upload zips. You point subdomains at folders. The deploy story is what can you fit inside that box.
So the architecture changed to fit the deploy:
The customer-uploaded files (PDFs, images) had to live somewhere. Default reach for AWS S3. I did not.
Reasons:
api/uploads/ftp-temp/ -> basic-ftp streams to public_html/ -> local temp deleted -> public URL returned.connectionLimit: 5. Shared hosting connection cap is real.Most Prisma tutorials assume Postgres. The default MySQL connector works against MariaDB but has small quirks, and shared hosting has a tight connection limit. Fix - use the dedicated MariaDB adapter.
// api/src/services/prisma.ts
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
const adapter = new PrismaMariaDb({
host, user, password, database,
connectionLimit: 5,
})
const prisma = new PrismaClient({ adapter })The catalog has two kinds of things - services (print jobs, configurable) and products (SKUs on a shelf). I wanted both to flow through the same Cart, Order, and Wishlist tables. So I made them the same shape.
A CategoryPricingRule carries a BASE_PRICE config. When an admin publishes a rule, the system creates a real Product row from it - same image, same spec structure - and tags it generatedFromPricingRule = true. Cart, order, wishlist all key on productId. Service jobs and physical SKUs use exactly the same code path.
When the rule changes, a resync rewrites the Product. Old orders stay correct because every OrderItem.metadata carries a snapshot of the price breakdown taken at order time. Rules can change; old invoices cannot.
effectivePageCount from spec metadata. Client cannot send the field. Off by ₹0.20 = customer email.Picking Both Sides duplex-prints a 200-page PDF onto 100 sheets. That changes the base price (paper is per sheet, not per page), the page-controller cap, and the binding addon range. Each in different ways.
Two pieces:
CategorySpecificationOption.metadata.isHalfPage lives on the option, not the category. So 'Sides = Both Sides' carries the flag; 'Sides = Single' does not.Second hard part - which page count does the addon range gate on? After a few iterations - ranges gate on raw pageCount × copies, not the half-page-reduced count. Why? Because a binding addon for 'books up to 200 pages' is about the book the customer holds, not how the press lays out paper.
A copyMultiplier flag covers per-copy-pages addons - binding is one binding per copy, regardless of page count. Three flags (quantityMultiplier, fileMultiplier, copyMultiplier), eight permutations, one helper, persisted price breakdown.
Get it wrong by ₹0.20 on a ₹500 invoice and a customer will email you. Get it right and they never notice.
Real flow: browses anonymously, configures a 200-page color-printed booklet with four addons, hits Add to Cart, then Checkout. Now they have to log in. Do not lose anything.
Two hard parts - `sessionStorage` has a quota (~5-10 MB per origin) and files only live as in-memory `File` objects that die on the login redirect remount.
Strategy in web/lib/utils/pending-purchase.ts:
QuotaExceededError -> retry without file payloads. Keep only metadata - names, sizes, specs, addon IDs, template form. Better to ask the user to re-pick a file than lose the whole configuration.pending-cart-intent.ts: read pending data -> convert base64/blob URLs back to File -> re-upload to FTP -> validate every addon ID against live rules (silently drop stale) -> reconstruct metadata -> re-issue add-to-cart.trailingSlash: true. Hostinger proxy adds slash, Next default doesn't. Loop.Symptom: account pages would not load. Network tab showed /orders getting 308'd to /orders/, then RSC payloads getting re-fetched, then redirected again. Inconsistent. Looked like a Next bug at first.
Cause: Hostinger's reverse proxy adds a trailing-slash redirect. Next.js's default canonical URL is no trailing slash. Each side thought the other was wrong. Loop.
Fix:
// web/next.config.js
const nextConfig = {
trailingSlash: true,
}PendingPayment row. Success-handler vs webhook race -> idempotent. Both gateways behind same `Payment` model - old orders stay readable.The first version used PhonePe. Worked but friction-heavy - signed base64 payloads, S2S-only webhook verification, slower refund APIs. After ~a month live, we migrated to Razorpay.
Razorpay Standard Checkout flow:
POST /v1/orders (HTTP basic auth key_id + key_secret). Get back order_id.order_id, amount, customer details.razorpay_order_id, razorpay_payment_id, razorpay_signature.HMAC-SHA256(razorpay_order_id + '|' + razorpay_payment_id, key_secret) must equal razorpay_signature.X-Razorpay-Signature so the backend can confirm payment even if the customer's browser closes mid-redirect.USED lock. Whichever path arrives first wins; the other returns 200.The interesting part is the race - the success-handler call and the webhook can both land first. Both want to create the order.
My fix - two-phase order creation:
PendingPayment row with a 1-hour TTL.PendingPayment.status to USED in the same transaction that creates Order + OrderItem + Payment. The other path arrives, finds status = USED, returns 200 idempotently.ChunkErrorHandler + flush Hostinger cache before AND after every deploy. Step zero, step last.Long-lived tabs hold references to chunk hashes. You deploy, chunks change, the user clicks something that lazy-imports a component, browser tries to fetch a chunk that no longer exists. White screen.
Three-part fix:
// next.config.js
webpack: (config, { dev }) => {
if (!dev) {
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
vendor: { test: /node_modules/, priority: 20 },
common: { minChunks: 2, priority: 10 },
},
}
}
return config
}error events on dynamic imports and surfaces a 'Reload to update' CTA instead of a white screen.index.html pointing at chunks that no longer exist - which causes the exact white-screen the first two fixes prevent. Manual flush is part of the deploy checklist now.onDemandEntries tuning (maxInactiveAge: 25_000, pagesBufferLength: 2). Production chunk errors mostly went away. The ones that remain are recoverable.CartMinimumError with details.shortfalls. Generic 'cart minimum not met' is useless - the customer needs to know which category, by how much.Multiple categories means multiple minimums. A generic 'your cart does not meet the minimum' is useless when you are holding ₹400 of A and ₹100 of B and both want ₹500.
Made the error structured. A custom CartMinimumError extends ValidationError carries a details payload:
{
"shortfalls": [
{ "categoryId": "...", "categoryName": "Prints", "required": 500, "current": 400 },
{ "categoryId": "...", "categoryName": "Booklets", "required": 500, "current": 100 }
]
}Most starter auth code assumes email + password. I switched to phone-OTP-first because that is what Indian customers actually use.
The migration was destructive:
phone mandatory and unique.password_reset_otps table.phone_otps table with OTPPurpose enum (SIGNUP, RESET_PASSWORD).(phone, purpose) - SIGNUP and RESET_PASSWORD OTPs for the same phone do not collide.User table; isAdmin/isSuperAdmin flags decide permissions.Subtle bug class - your invoice shows ₹500.20 but the order total says ₹500.00. Floating-point math + page counts × per-page rates + addons + percentage coupons makes silent drift easy.
Fix: persist the breakdown at order time, never recompute on render. OrderItem.metadata.priceBreakdown is a JSON array of every line, stored at add-to-cart, locked forever. The PDF renders the persisted rows. The sum is the persisted total. No drift, ever.
The PDFKit invoice strips annotation-only rows (like 'Both Sides: 100 -> 50') via an isInfoRow flag so they do not show as ₹0.00 lines. Logo loads from a multi-candidate path search with caching, falls back to a wordmark if missing - so invoice generation never dies because of a path mismatch between dev and prod.
trailingSlash: true, hours of diagnosis.PendingPayment row, idempotent success path.Pagz was a freelance project. The client owned a print shop, wanted to move off WhatsApp orders and manual ledgers. They needed a real online store - configurable print jobs, online payments, a clean admin panel, and a workflow that pushed customer files straight to the printer. They handed me the brief and the budget. I built it.
Four months, around 300 commits, three Node.js apps in production on Hostinger. From empty repo to live storefront, payment gateway swapped from PhonePe to Razorpay mid-flight, every screen tested with the actual shop owner, every bug closed at the root.
If you are hiring, what I want you to take from this:
Open to full-time roles and freelance projects. If you are building something hard - e-commerce, payments, complex schemas, anything where the boring layers eat your week - drop a message.