Most SaaS case studies are about building the next project management tool or CRM. This one is about brick factories. Real ones — with raw material inflow, production tracking, per-customer pricing, and GST-compliant invoicing.
I built Brick Management System as a full SaaS product that now manages multi-factory operations. This post covers the technical decisions, architecture, and lessons from building a domain-specific SaaS from scratch.
The Problem
Brick manufacturing in India is a largely undigitized industry. Factory owners track operations on paper or in basic spreadsheets. The challenges:
- Raw material inflow (clay, coal, sand) needs tracking with supplier details and quantities
- Production records — bricks produced per day, by type, with wastage
- Customer management — each customer has different negotiated prices for the same brick type
- Sales and invoicing — generating GST-compliant invoices with proper HSN codes
- Multi-factory — owners often run 2-3 factories and need a consolidated view
- Role-based access — owners, consultants, and factory admins all need different levels of access
No existing tool fits this niche. Generic inventory tools don't handle per-customer pricing. Generic invoicing tools don't understand raw material to production workflows.
Architecture Decisions
Tech Stack
Frontend: React + TypeScript + Tailwind CSS + Redux Toolkit + RTK Query
Backend: Node.js + Express + Prisma + PostgreSQL
PDF Generation: PDFKit
Build: Turborepo monorepo
Why this stack:
- React + TypeScript — type safety across the entire frontend. With RTK Query, the API layer is fully typed from backend to frontend
- Redux Toolkit + RTK Query — excellent caching, optimistic updates, and automatic refetching. For a data-heavy dashboard, this is essential
- Prisma + PostgreSQL — type-safe database access with migrations. PostgreSQL handles the complex queries needed for reports and dashboards
- Turborepo — the frontend and backend share types and validation schemas through a monorepo structure
- Node.js + Express — I chose Express over FastAPI for this project because the entire stack is TypeScript, and shared types between frontend and backend eliminated an entire class of bugs
- PDFKit — server-side PDF generation for GST-compliant invoices
This is the same TypeScript full stack approach I follow across all my projects — shared types, Zod validation at boundaries, and Prisma for the database layer.
Database Design
The data model centers around these entities:
Factory → has many → Materials (inflow)
Factory → has many → Productions (daily output)
Factory → has many → Customers
Customer → has many → PricingRules (per brick type)
Customer → has many → Sales
Sale → has many → LineItems
Sale → generates → Invoice (with GST)
Factory → belongs to → Organization
Organization → has many → Users (with roles)
Per-customer pricing was the trickiest part. Each customer negotiates different rates for different brick types. The pricing table stores these as:
model CustomerPricing {
id String @id @default(cuid())
customerId String
brickType String
pricePerUnit Decimal @db.Decimal(10, 2)
effectiveFrom DateTime
customer Customer @relation(fields: [customerId], references: [id])
}When creating a sale, the system automatically looks up the customer's negotiated price for each brick type. If no custom price exists, it falls back to the factory's default price.
Role-Based Access Control
Three roles with distinct permissions:
| Role | Access Level |
|---|---|
| Owner | Full access to all factories, all data, user management |
| Admin | Full access to assigned factory only |
| Consultant | Read-only access + commission tracking across assigned factories |
The consultant role was interesting — factory owners hire consultants who refer customers and earn commissions on sales. The system tracks referrals and calculates commissions automatically.
RBAC is implemented as middleware:
function requireRole(...roles: Role[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = req.user;
if (!roles.includes(user.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
// For admin role, verify factory assignment
if (user.role === "admin" && req.params.factoryId) {
const hasAccess = await checkFactoryAccess(user.id, req.params.factoryId);
if (!hasAccess) {
return res.status(403).json({ error: "No access to this factory" });
}
}
next();
};
}GST-Compliant Invoice Generation
Indian businesses need GST-compliant invoices with specific fields: GSTIN, HSN codes, CGST/SGST/IGST breakdowns, and a specific format. I generate these server-side with PDFKit:
async function generateInvoice(saleId: string): Promise<Buffer> {
const sale = await prisma.sale.findUnique({
where: { id: saleId },
include: {
lineItems: true,
customer: true,
factory: { include: { organization: true } },
},
});
const doc = new PDFDocument({ size: "A4", margin: 50 });
// ... layout code for GST-compliant format
// Includes: seller GSTIN, buyer GSTIN, HSN codes,
// taxable value, CGST, SGST, IGST, total
return doc;
}The invoices include sequential numbering per financial year, QR codes for verification, and proper tax breakdowns based on whether the sale is intra-state (CGST + SGST) or inter-state (IGST).
Stock Tracking with Adjustment Workflows
Real factories have discrepancies between expected and actual stock. The system handles this with an adjustment workflow:
- Expected stock = raw material inflow - material used in production
- Actual stock is entered during physical counts
- Adjustments record the difference with a reason (wastage, theft, measurement error)
- Audit log tracks every adjustment with who made it and why
This was important because factory owners often discover stock doesn't match records. Rather than just letting them edit numbers, the adjustment workflow creates an audit trail.
Server-Side Pagination and Performance
With factories generating hundreds of sales per month, performance mattered. Key optimizations:
- Server-side pagination with cursor-based pagination for large datasets
- RTK Query caching — data is cached on the frontend and only refetched when stale
- Database indexes on frequently queried columns (customerId, factoryId, createdAt)
- Aggregation queries for dashboard stats run as raw SQL through Prisma for performance
// Cursor-based pagination
const sales = await prisma.sale.findMany({
take: pageSize + 1,
cursor: cursor ? { id: cursor } : undefined,
where: { factoryId },
orderBy: { createdAt: "desc" },
include: { customer: true, lineItems: true },
});
const hasMore = sales.length > pageSize;
const items = hasMore ? sales.slice(0, -1) : sales;Multi-Factory Dashboard
Owners with multiple factories need a consolidated view. The dashboard shows:
- Total production across all factories
- Revenue and outstanding payments per factory
- Stock levels with low-stock alerts
- Top customers by volume and revenue
- Consultant commissions pending and paid
Each widget makes a separate API call, and RTK Query handles the caching and loading states. The dashboard loads fast because data is fetched in parallel.
Lessons from Building Domain-Specific SaaS
1. Domain expertise is the moat. Any developer can build CRUD. Understanding that brick factories need per-customer pricing, GST compliance, and consultant commission tracking — that's what makes the product useful.
2. Talk to users before building. I spent time with factory owners understanding their workflows before writing any code. Features I thought were important (real-time dashboards) were less valued than features I almost skipped (stock adjustment audit trails).
3. Monorepo pays off for full stack SaaS. Sharing types and validation between frontend and backend eliminates an entire class of bugs. When I change a Prisma model, TypeScript errors immediately surface everywhere the type is used.
4. Start with the hardest feature. I built the invoicing system first because it had the most regulatory constraints. Everything else was easier after that.
5. PDFKit is underrated. Server-side PDF generation with full control over layout is powerful. No need for headless browsers or third-party PDF services.
What's Next
The system is production-ready and being used by factory owners. Next steps include:
- Mobile app for factory floor data entry (daily production, stock counts) — following the same React Native production patterns I use across all my mobile projects
- WhatsApp integration for sending invoices directly to customers
- Analytics dashboard with production trends and demand forecasting
Building niche SaaS is rewarding — the users genuinely need the tool, and there's no competition from generic solutions. If you're a full stack developer looking for a project, find an underserved industry and build for them.
You can see this project and others on my portfolio. For more technical deep dives, check out my blog.
I'm Manjodh Singh Saran — a full stack developer from Ludhiana, India. I build production SaaS products and mobile apps. Check out the Brick Management System live.