By the Quantiq Automation team, Ahmedabad. Written October 2026.
We want to tell this story properly, because most ERP "success stories" you read online skip the boring and painful parts. Those parts are where the real learning is. So this is the long version: how a quotation sheet for one fabrication shop turned into a multi-company ERP, which decisions we got right, which ones we got wrong, and what we would tell anybody who is about to build a similar thing.
The product is called Quantiq Systems, and you can see it at erp.quantiqautomation.com.
One thing before we start, because it shapes everything below. Quantiq Systems is built for unlimited users and very low cost. Not "unlimited" with an asterisk, and not "cheap" because it does less. We designed it so that adding a person to a company costs nothing extra, and so that running the whole thing costs a flat, small amount per month. That was a design goal from day one, and a lot of the technical choices in this story were made to protect it.
1. Where it started: one fabrication shop and a quotation that never matched
It did not start as "let us build an ERP". It started with a real client, a fabrication business, whose owner was quoting jobs in a spreadsheet and billing from another one. Metal sheets, pipes, bars, angles, welding electrodes. Every job priced by area, by weight, by length, or by piece, sometimes all four on the same quotation.
The first client we looked after was DS House. Their quotations are for screens, and a screen quotation has its own logic. There is a costing block that the customer should never see as a bill, a "total weight" line, a scrap percentage added on the last priced line, a rounding step to the nearest hundred rupees, and finally a rate per square foot that is derived from all of that and then multiplied by the area of each screen. If you get one of those steps wrong, the number on the paper is wrong, and the customer notices.
So the very first piece of "ERP" we wrote was not a database or a dashboard. It was a document engine: sections that can be items, costing, or screens; price-by-measure (area, length, weight, pieces); GST split by rate; and one shared function, finalizeSections(), that the editor preview and the server both call. That detail turned out to be important. The number you see while typing and the number saved on the server are computed by the same code, so they cannot disagree.
Money is stored as integer paise everywhere. Never floating point. This is a small decision that saved us from a whole class of "why is the invoice one paisa off" bugs, and we recommend it to anyone.
Then came the usual list: customers, vendors, items with stock, sales orders with payments, purchase ("receive") orders, employees, attendance, payroll with employee debts that auto-repay from the monthly sheet, an inbox, and an analytics dashboard that estimates the physical weight of steel from the item specs (mild steel density, per-shape formulas). That was version one.
The cycle the whole product is built around: enquiry, quotation, order, production, stock, bill, payment, and the reminders that chase each step.
2. Version one was a JSON file, and that was fine (for a while)
The first version stored everything in one file, data/db.json. People sneer at this, but for one business with a handful of users on one laptop it was the right call: no database to install, nothing to configure, and we could change the shape of the data daily while we were still learning the business.
What mattered was not the file. It was that we put contracts between the business logic and the storage. The app talks to "repositories" (customers, items, orders, quotations, vendors, purchases) and a unit of work that is all-or-nothing. The code even had a comment in it saying that swapping to Postgres meant implementing the contracts and changing one line in the container.
That comment was a promise to our future selves, and we kept it. When the time came to move to Postgres, we did not rewrite the app. We wrote a second implementation of the same contracts. Today the test suite runs against both backends, the JSON one and a Postgres one (using an in-process Postgres for tests), and the same tests must pass on both.
The pain of version one was also clear to us on paper, and we wrote it down in an audit on 14 October. In short: no login, no users, no roles, no concept of more than one company, no audit trail, and a storage file that cannot live on a serverless host because there is no writable disk. It was a good single-company tool. It was not a product.
3. The day it became a product: "one product, not a messy setup"
The turning point was a conversation about a second client. Client A was the fabrication shop (metals, welding, tools). Client B was a machinery maker, packaging machines and other machines, built from bought-in parts, in-house parts, and outsourced parts. More clients would follow, each slightly different.
The requirement Jay gave us was blunt and very practical: one product. Not a copy of the code per client. Not a setup where somebody has to manually untick things so that one client does not see another client's items. When a new client is created, they get a clean, empty company, with only the tabs they are meant to have, and only the actions (view, create, edit, delete) that were agreed.
From that came three ideas that still define the system:
- A control plane. A separate "platform" area, used only by us, where we create companies, choose their industry, switch modules on and off per company, set what each module allows, suspend a company, and visit a client's system for support. The visit is clearly marked and every action is written to an audit log.
- Industry packs as data, not code. An industry is a bundle of modules. Creating a company picks the bundle. We do not fork code for Client B.
- Three layers of permission. What a company is entitled to (set by us), what its admin gives each user (always inside the entitlement), and what a person can see at field level.
The shape of the system today: people on one side, one Next.js app in the middle, Postgres on the other side, and a couple of outside services (a cron and each client's own Gmail) around the edge.
4. The big technical fork: how do you keep two companies from ever seeing each other's data?
This is the question every multi-company system has to answer, and it is the one we spent the most careful thinking on, so it gets the longest section.
There are three common answers. A separate database per company is the safest and the most expensive and the hardest to update. A shared table with a company_id column is the cheapest and relies on every single query remembering the filter. And in between, a separate schema per company.
We did not want to decide by opinion, so we ran a spike. We created 300 schemas, each with 24 tables and its own database role, on a free Supabase instance. About 39,000 relations, 7,200 tables, 285 MB. We measured creation time (60 ms for an org at the start, about 90 ms by org number 300), query speed with thousands of rows per table, and a fleet-wide schema change across all 300 companies (2.3 seconds). We also tried to break isolation: from inside one company's role, we tried to read another company's schema, write to it, change structure, and read the control schema. Every attempt was refused.
The spike also taught us something we would never have found by thinking. You cannot create about 45 companies in one transaction, because Postgres runs out of shared lock memory. Fine in real life (you provision one company at a time), but a fleet-wide migration has to run in small batches. It was a good reminder that "works in theory" and "works at 300" are different sentences.
The verdict was that schema-per-company is viable up to at least 300 companies. Even so, the design we settled on for the business data is one schema per industry, shared by all companies in that industry, with every row carrying a client_id. A new industry means a new schema, never a new database per client. That keeps schema changes as one operation instead of hundreds, while the wall between companies is enforced by the database itself rather than by hope.
The three walls
Because a shared table is only as safe as its weakest query, we put three independent walls in place, so that a mistake in one of them does not leak data.
- Wall 1, the column. Every business row has
client_id NOT NULL. The app does not pass it. It defaults from a per-transaction setting (app.client_id). If that setting is not set, the value is NULL, which means no rows are visible and no rows can be written. - Wall 2, the foreign keys. Every link between tables is composite,
(client_id, id). A quotation of company A cannot point to a customer of company B. The database itself rejects it. We tested this by trying. - Wall 3, row-level security. Each tenant table has a policy
client_id = current_client_id(). The application connects as a restricted role (erp_app), never as the owner. The owner role bypasses row-level security, so the app must never use it. We wrote that rule down in capital letters and then built tests around it.
A separate role (erp_platform) is used only by the control plane. Two different passwords, two different roles. Setting those passwords is deliberately left to a human in the Supabase editor, never to an assistant, and the assistant helping us was blocked from doing it, correctly. That is how it should be.
On top of the walls, every company's writes run one at a time through a per-company advisory lock inside the write transaction. That sounds heavy, but one company is a handful of people, and it removed a large family of race conditions in one stroke.
5. Where to host it, and the free-tier reality check
The original plan was to test on Zoho Catalyst, which has a student programme with a USD 250 usage allowance. We read the documentation closely and listed our worries: Next.js start-up time (10 seconds to listen, 30 seconds per request), whether outbound connections to Postgres are allowed, memory. We built a probe app to answer those questions: a small Next.js build that tests the database connection, PDF generation, and Excel generation, with a "copy report" button. Locally, a seven-page PDF took 0.6 seconds and a 30,000-row Excel took 1.5 seconds, and the process peaked at around 470 MB, which told us the 512 MB default would be too tight.
Then life moved on. The probe report never came back from Catalyst, and the decision was made to host on Vercel, in the Mumbai region, with Supabase in Mumbai for Postgres. We are saying this out loud because it is a lesson too: we spent real time on a test that never produced a result, and the decision got made by simpler facts. If you have a deadline, write down the "good enough" option next to the "ideal" one before you start probing.
The Mumbai move
Our first Supabase project was in the US (us-east-1). We noticed the region early and wrote it down in the Phase 0 report, and then moved: a new project in Mumbai, migrations re-run from scratch, roles created, the US project removed. Indian users would otherwise pay a latency tax on every click. If your users are in India, make this decision on day one.
What "free" really means
We also had to read the small print on the free tiers, because we were planning to run on them for the first two (non-paying) users. A few facts we verified and now keep on a wall:
- Supabase Free has no automatic backups and pauses a project after a week of inactivity.
- Vercel Hobby is for non-commercial use only. A paying client means Vercel Pro.
- The plan for a paying client is Supabase Pro ($25) plus Vercel Pro ($20), about $45 a month, flat, no matter how many users a company adds. (That figure was verified on 7 October 2026 and should be rechecked before buying.)
That last point is the reason unlimited users is possible. There is no per-seat licence in the cost structure, so we do not need a per-seat price.
We also had a scare. The Supabase usage page showed "Log Query: 17.5 GB of 100 GB" and for a moment it looked like a quota was close. It was not. That number counts data scanned when you read logs, not data stored. The real numbers were tiny: 30.9 MB of the 500 MB database, 0.007 GB of the 5 GB egress. Reading logs back-to-back over wide windows, and leaving the logs page auto-refreshing, scans the same data again and again. We changed our habits instead of our architecture.
The honest cost-side risk we found was different: with ten users loading hundreds of screens a day, egress would hit the free limit before storage ever does. So the fix is in the code, not the plan: paginate lists, select only the columns a screen needs, do not poll full rows.
6. The bug that scared us most: two people, one record
Here is the issue we are most glad we caught, because it is silent.
A user opens a quotation. Another user changes it and saves. The first user, who still has the old version on screen, saves later. Result: the second person's change disappears. No error. Nobody knows.
When we audited it (18 and 19 October), it was worse than we had assumed. Only custom-tab records had a version check. Items, customers, vendors, employees, jobs, quotations, recipes and work orders all saved with a plain overwrite. Two nasty cases stood out:
- A quotation save deletes and recreates all its sections and lines. A stale save could therefore wipe the other person's line items.
- The item form sent back the stock figure it was opened with, and the service treated any difference as a stock correction. An old form could put old stock back.
Some things were already safe, because of the per-company write lock, the row lock on stock moves, and idempotent status changes. But "some things are safe" is not a feeling you want when someone is entering invoices.
The fix is what database people call optimistic concurrency. Ten tables got a version column, and one trigger bumps it on every update no matter who makes the update (a form, a stock trigger, a payment trigger). Every edit request carries the version the person loaded. The server calls assertFresh() right after reading the row, inside the locked write, so nothing can change between the check and the save. A mismatch returns HTTP 409 (stale_edit) and saves nothing. The screen shows a calm notice: someone else changed this while you were editing, load the latest version.
One more small piece of care: the edit pages are keyed on the record's last-update and version, so loading the latest version rebuilds the form from fresh data. Otherwise, old typed values could be saved over the new version, which would defeat the whole point.
We are also honest about what is not covered yet: the users admin screen, business settings, payroll and attendance sheets, and custom field definitions still use last-write-wins. The collision risk there is low (one admin), and we wrote that down instead of pretending.
7. Fabrication is not manufacturing (and we changed our minds twice)
We started with one industry, fabrication, because that is what Client A is. When Client B (the machinery maker) arrived, the first instinct was a second industry that shares everything and adds "recipes" (a bill of materials: this machine is made from these parts, some bought, some made, some outsourced).
The first version of the split (18 October) made production, which is work orders and the shop floor, a Manufacturing-only module. Then the real world pushed back: Steel Concept, a fabrication client, already used work orders and the shop floor. They needed production, but not recipes. So on 19 October we changed it again. Production became a shared module, and recipes became their own module, boms, Manufacturing only.
Shared by both industries: dashboard, customers, items, vendors, quotations, bills, purchases, jobs, employees, attendance, payroll, production. Fabrication adds the two cash books. Manufacturing adds recipes.
The lesson is a design one. Make a module small enough that the answer to "does this client get it?" is yes or no. Our first split was too coarse, so it needed to be redone while a client was already using the system. Because modules are rows in a table and not branches in code, the change was a migration, not a rewrite. We also had to be careful with data: Steel Concept kept its two existing recipes in the database, hidden, rather than losing them.
A company's industry can never change after creation. That is enforced by a trigger. It sounds rigid, and it is, on purpose: the choice you make at creation is the wall, and nobody has to remember to untick things later.
8. The control plane: us versus them
The platform console is where a company is created. You choose the industry, and it offers that industry's modules plus the shared ones. For each module you set what is allowed: view, create, edit, delete. A company admin then hands out a subset to their own users, and can never grant more than the company is entitled to. The database checks this at commit time, not just the screen.
Companies never see that screen. The dashboard respects it too: a section is only calculated for people who are allowed to see that module.
On top of that we added capability switches, one row per company: may they customize, may they use reminders, may they send mail from their own Gmail, may they use AutoPilot. These are enforced server side. If the switch is off, the route answers 403, and there is no Customize screen to find.
Platform staff have their own roles too. Not every person at Quantiq needs the power to create companies or read audit logs, so those rights are grantable per area (for example, the Website tab has view, edit and delete rights).
We also built the ERP's own MCP server, with OAuth 2.1 and personal access tokens, so that we can connect it to Claude and say "create a client" or "add a quotation" and have the real service layer do it, with every call audited. The tools wrap the service layer, not raw SQL, so the same rules apply to a person and to an assistant.
9. The same mistake, three times: the migration that was never applied
If there is one lesson in this post that we paid for more than once, it is this one.
On 19 October, the app returned 500 errors: "column i.version does not exist". The new code (which reads version) had been deployed before the migration that creates the column. We applied migration 0021, and found that 0020 had not been applied either.
On 21 October, every client page threw "something went wrong" on the Reminders and Customize tabs. The deployed code read custom.fields.config, and migration 0025 had never been applied to the live database.
On 22 October, the platform console's Website tab showed only "Something went wrong". Migrations 0026, 0027 and 0028 (mail centre, AutoPilot, website enquiries and reviews) had never been applied. The same missing migrations would have broken Mail and AutoPilot in every client app.
Three incidents, same cause, three different days. The generic error page did what it should (it did not leak details), but it also gave us nothing to go on, and we lost time looking in the wrong places.
The rule we now follow, and have written in the go-live checklist: run node scripts/migrate.mjs before, or together with, every deploy that adds a file under db/migrations, and run it with --dry first. The runner records every applied file in erp_ops.schema_migrations, so it is safe to run twice. The code and the database must move together. A migration file sitting in a Git repo that the live database has never seen is a loaded gun.
The tooling gotchas that went with it
We also ran into a handful of tool quirks that cost us hours, and it is worth passing them on:
- The Supabase connector we use for changes hangs for about three minutes on SQL text that contains destructive keywords such as
drop, even inside a function body. We stopped fighting it. Policies are created through a helper that never needs a drop, and cleanup SQL is run by a person in the Supabase editor. - Big migrations sent in one call can time out and roll back. Sending them one statement per call works.
- Never delete and re-grant a company's module rows to "reset" them. The delete cascades into user permissions.
- A leftover pair of old function overloads (
create_fieldwith 10 arguments,update_fieldwith 8) still sits in the database because dropping them through that connector hung. They are harmless, since the app only calls the new signatures, and are on the list to drop by hand.
10. What we built on top once the base was solid
Once the walls, the roles, and the control plane were in place, new features became a lot cheaper to add, because they inherited the safety. Here are the main ones, in the order they landed.
Reminders and a mail centre (20 and 21 October). A rule engine that chases payments and follow-ups, woken by an outside cron every minute (Vercel Hobby crons are daily only, so we use an external one with a secret). Each company can send mail from its own Gmail: the 16-letter app password is encrypted with AES-256-GCM before it is stored. Outgoing mail has open and click tracking. Gmail allows about 500 mails a day, so the per-company limit defaults to 400.
Customize (20 and 21 October). Each company can add its own tabs and fields, within limits we set. Field types include text, numbers, currency, dates, dropdowns (up to 30 options), lookup fields that pull values from another record with conditions, and formula fields, evaluated by our own small parser. No eval, ever. A custom tab is a module like any other, so view, create, edit and delete per user already work.
AutoPilot (22 October). A workflow engine fed by an outbox of events: when something happens (a quotation is won, a payment is late), AutoPilot can update fields, including on related records, draft a mail, create a task, or call a webhook. Mail is drafted for a person to review, not blindly sent.
A public website inside the same app (22 October). Home, Clients and Reviews pages, with a contact form that saves the enquiry first and then forwards it to our CRM. Reviews wait for approval in the console before the public can see them, and the product pictures on the site are drawn in code with sample data and labelled as such. It lives at erp.quantiqautomation.com.
11. Security: the boring list we are glad we wrote
Security is where a small team is tempted to say "we will do it later". We did not, and the reasons are all in the sections above, but here is the checklist in one place:
- Passwords hashed with scrypt. Opaque server-side sessions.
SameSite=Strictcookies. - A nonce-based Content Security Policy, and HSTS.
- Login throttling stored in the database (so it survives serverless instances), not in memory.
- An
api()wrapper that fails closed: every API route must declare who may call it, or it simply will not load. A test walks every route to prove it. - A 60-check smoke test, and database-level tests that try to break the walls between companies on purpose.
- An audit log on changes (
withAudit), including every support visit and every action by platform staff. - A real test suite: 867 tests pass in the JSON backend, and the Postgres backend (in-process Postgres) and SQL-level tests (isolation, cross-company foreign keys, numbering, stock, payments, entitlements) run as well. A few Postgres tests are slow in our cloud sandbox and time out at five seconds with or without our changes. We know, and we say so.
12. Smaller things we learned, quickly
- Lists that look right on a phone. The shop floor uses phones. Lists became cards on a phone and tables on a desktop, and the shop-floor screen has big buttons. A production tool that cannot be used with greasy fingers is not a production tool.
- Units matter. Sheets, feet, kilograms, pieces: we extended the unit list and made price-by-measure part of the document engine instead of a special case.
- Stock is a ledger, not a number. Stock movements are append-only rows and a trigger maintains the current stock. The app cannot write the stock figure directly. Corrections are new rows. It is slower to design and much faster to trust.
- Numbering is atomic and gap-free per company, with an optional financial-year suffix (for example
INV-0007/26-27). Never truncated. - Sample data and a guided tour were added because a blank system is hard to learn. The sample data respects the company's modules, so a fabrication company does not get recipes in its demo.
- Real client data stays out of screenshots. Every picture of the product we publish comes from demo data.
13. A timeline of the build
| When | What happened |
|---|---|
| September | Single-company app on a JSON file. Documents, GST, payroll, analytics dashboard, glass-style redesign. |
| 30 September | DS House screen quotations: costing block, weight, scrap percentage, rate per sqft. Sorting and filters in every list. Employee debts. |
| 13 to 14 October | Audit of the system, Phase 0 on Supabase (300-schema spike), fab database design, platform tables, first MCP design. |
| 17 October | Go-live plan: Mumbai project, Vercel, customization switches, custom fields and tabs. |
| 18 October | Unit dropdown, price-by wizard and in-app guide, receive orders that can be shared across several clients, first industry split, usage and free-tier review. |
| 19 October | Version column and 409 on stale saves. Fabrication versus Manufacturing, second version. |
| 20 October | Platform staff roles, table-and-card lists everywhere, mail and reminders, go-live checklist. |
| 21 October | Lookup and formula fields, mail centre, logo. Incident: migration 0025 missing. |
| 22 October | AutoPilot (including updating related records), public website, incident: migrations 0026 to 0028 missing. |
14. What we would tell someone starting today
- Put contracts between your logic and your storage on day one. We started on a JSON file and moved to Postgres without a rewrite because of that.
- Decide the tenancy model with a spike, not an opinion. The 300-schema test gave us real numbers and one surprise (the lock limit).
- Use more than one wall. A
client_idcolumn alone is a promise. Add composite keys and row-level security so a single mistake does not become a leak. - Never let your app use the owner database role.
- Version every editable row. Silent overwrites are the worst kind of bug because nobody sees them.
- Deploy the migration and the code together. We repeated this one three times.
- Read the free-tier small print before you plan around it. Backups, pausing, and commercial-use rules all matter.
- Pick your region for your users. For us that was Mumbai.
- Make modules small. Industry rules are changed by data, not by branches in code.
- Write down what you did not cover. Our "not covered yet" lists are the most useful pages we have.
15. Where we are now, and what is next
Today Quantiq Systems has a working multi-company core, two industries (Fabrication and Manufacturing), a control plane, per-company customization, reminders and mail, AutoPilot, and a public website. The two clients using it right now are DS House and Steel Concept. We are being careful about how we describe that: both are early, non-paying relationships, and we would rather say so than pad the story.
What is next is on the list and not a secret: add the same stale-save check to the screens that still overwrite, more per-industry dashboard widgets (late work orders, in-progress jobs), the decision on whether a "sales order" in Manufacturing is only a label or a different flow (order, production, dispatch, invoice), the original bill-of-materials vision (group the parts of a machine by vendor and send each vendor one purchase order, and track each vendor's status), and real client stories on the website, with each client's permission.
If you run a fabrication or manufacturing business and you are tired of paying per user for software that does not know what a sheet of 5 mm mild steel is, we would like to talk. Unlimited users, very low cost, priced per business. See the product at erp.quantiqautomation.com, call 94998 88395, or write to info@quantiqautomation.com.
Prices and costs mentioned are verified at the dates given and should be rechecked before you rely on them.
Want help implementing this?
Tell us about your workflow and we'll put together a tailored plan.
Book a Free Auditarrow_forward