The challenge
A steel fabrication business ran its leads, estimates and customer follow-ups by phone and paper, with no way to quote consistently or show customers where their order stood.
The approach
Built a Next.js 16 application that is both the public website and the back office.
What shipped- Lead and quotation intake protected by Cloudflare Turnstile and rate limiting
- An admin panel with a costed fabrication-estimate engine (templates, material rates per kg, charges, tax and margin)
- A seven-state quotation lifecycle with an activity log
- PDF quotes rendered through a hosted browser API
- MongoDB persistence
- OCI Object Storage for drawings and attachments
- A client project-tracking portal
The outcome
Leads become costed quotations that are emailed as PDFs and accepted, rejected or sent back for changes by the customer through a single-use secure link, with stale quotes expired by a daily job and every step recorded.
How FabWorks is put together
A Next.js Pages Router application that is both the public website and the business back office: lead and quotation intake, a client project-tracking portal, and an admin panel with a costed fabrication-estimate engine and a quotation lifecycle that ends in a customer accepting or rejecting a PDF quote by secure link.
- quotation states, enforced transitions
- 7
- single-use quote links, stored hashed
- 256-bit
- MongoDB models
- 16
- function budget the PDF design fits
- 1 GB / 10 s
01 · Clients
client
Visitors & customers
client
Admin staff
02 · Next.js app
gateway
Middleware
per-route cache policy
service
Public site
services, gallery, quotes, tracking · i18n
service
Admin panel
content, leads, estimates
03 · API
service
API routes
Zod DTOs, JWT cookie, rate limits
service
Quotation engine
calc engine, state machine, audit log
worker
Vercel Cron
expire quotations daily
04 · Data
database
MongoDB
Mongoose
16 models
object store
OCI Object Storage
drawings, attachments
05 · Third-party
third-party
PDF render API
hosted browser · Chrome fallback
third-party
SMTP
Nodemailer
third-party
Cloudflare Turnstile
bot check on public forms
14 connections
- Visitors & customers→MiddlewareHTTPS
- Admin staff→MiddlewareHTTPS
- Middleware→Public site
- Middleware→Admin panel
- Public site→API routesleads · quotes · respond
- Admin panel→API routesCRUD · estimates
- API routes→Cloudflare Turnstileverify
- API routes→Quotation engine
- Quotation engine→MongoDB
- API routes→MongoDB
- API routes→OCI Object Storageupload
- Quotation engine→PDF render APIHTML → PDF
- Quotation engine→SMTPquote email · async
- Vercel Cron→Quotation engineexpire stale · async
Request flow · From lead to accepted quotation
- 1A visitor requests a quote by materials, an uploaded drawing or a gallery design; Turnstile and a rate limit screen the form and the API stores an intake record.
- 2Staff build a costed quotation from line items, templates and material rates; the engine computes subtotals, charges, tax and margin.
- 3Sending renders the quote to PDF and emails it with a link carrying a random 256-bit token, of which only the hash is stored.
- 4The customer accepts, rejects or requests changes through that link; the state machine allows only legal transitions and a daily cron expires what was never answered.
Design decisions
- An explicit state machine
- draft → sent → viewed → accepted / rejected / expired, with cancellation from any open state; every transition is checked and written to an activity log.
- Links as hashed bearer tokens
- A quote link carries a random token; only its hash is stored, so a database read never yields a working link, and expired or revoked links answer 410 or 404.
- PDF rendering off the function
- Vercel Hobby limits (1 GB, 10 s) are too tight for in-process Chromium, so PDFs render through a hosted browser API behind one htmlToPdfBuffer() entry point.
- Intake and estimate stay separate
- A public request is a lead; a costed fabrication quotation is a bill of materials. Keeping them apart stops marketing forms from shaping estimating data.
- Cache policy at the edge
- Middleware marks forms and admin as no-store, catalog APIs as cacheable and static assets as immutable.
Trade-offs & known limits
- The client tracking portal is gated by order id and phone with a sessionStorage marker: convenient, but not a full authenticated session.
- WhatsApp sending is a pre-filled wa.me link the admin opens, not automated delivery.
Have something similar in mind?
I'm open for freelance and contract work — let's talk about what you're building.