Skip to main content

Overview

Inventory Pro is a full-stack multi-tenant SaaS application. Each layer has a distinct responsibility:

Frontend

Next.js 16 (App Router) with React 19 and Tailwind CSS. Handles routing, UI, and client-side session management via Supabase Auth.

Backend

Node.js with Express. Exposes a REST API on port 5000. Validates JWTs and enforces tenant isolation on every protected route.

Database

PostgreSQL accessed through Prisma ORM. Every table carries a tenantId column that scopes all rows to a specific tenant.

Auth

Supabase manages client-side sessions. The backend independently issues and verifies JWTs that carry both userId and tenantId.

Multi-tenant data model

The Tenant model is the root of the data hierarchy. Every resource in the system — users, products, services, customers, inventory records, and stock movements — belongs to exactly one tenant via a tenantId foreign key with cascade delete.
schema.prisma
model Tenant {
  id        String   @id @default(uuid())
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  users     User[]
  products  Product[]
  services  Service[]
  customers Customer[]
  inventory Inventory[]
  movements StockMovement[]
}
Each related model references Tenant through a tenantId field:
ModelKey fieldstenantId
Useremail, password, name, roleRequired
Productname, sku, priceRequired
Servicename, description, priceRequired
Customername, email, phone, addressRequired
InventoryproductId, quantity, minStock, maxStockRequired
StockMovementproductId, type (IN/OUT), quantity, reasonRequired
Deleting a Tenant cascades to all related records across every table.

Data isolation

Tenant isolation is enforced at the API layer, not the application layer. The auth middleware extracts tenantId from the verified JWT and attaches it to every incoming request:
backend/src/middleware/auth.ts
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization

  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing or invalid authorization header" })
  }

  const token = authHeader.slice(7)

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET || "your-secret-key") as any
    req.userId = decoded.userId
    req.tenantId = decoded.tenantId
    next()
  } catch (error) {
    res.status(401).json({ error: "Invalid token" })
  }
}
All route handlers then use req.tenantId as a mandatory filter in every Prisma query. A user from tenant A cannot read or modify data belonging to tenant B — even if they know the resource ID.

Authentication flow

  1. RegistrationPOST /api/auth/register creates a Tenant record and an associated admin User in a single transaction. The response includes a signed JWT.
  2. LoginPOST /api/auth/login verifies credentials and returns a JWT containing userId and tenantId.
  3. Token verificationGET /api/auth/verify confirms the token is valid without requiring a database lookup for the full user record.
  4. Protected routes — every request to /api/products, /api/services, /api/customers, /api/inventory, and /api/stock-movements passes through authMiddleware before reaching the route handler.

API routes

Route prefixVisibilityDescription
POST /api/auth/registerPublicCreate a new tenant and admin user
POST /api/auth/loginPublicAuthenticate and receive a JWT
GET /api/auth/verifyPublicVerify a JWT
GET /api/productsProtectedList products for the authenticated tenant
POST /api/productsProtectedCreate a product
PUT /api/products/:idProtectedUpdate a product
DELETE /api/products/:idProtectedDelete a product
GET /api/servicesProtectedList services
POST /api/servicesProtectedCreate a service
GET /api/customersProtectedList customers
POST /api/customersProtectedCreate a customer
GET /api/inventoryProtectedList inventory records
PUT /api/inventory/:idProtectedUpdate stock levels
GET /api/stock-movementsProtectedList stock movements
POST /api/stock-movementsProtectedRecord a stock movement
In index.ts, all protected routers are registered after the global authMiddleware:
backend/src/index.ts
// Public routes
app.use("/api/auth", authRouter)

// Protected routes
app.use(authMiddleware)
app.use("/api/products", productsRouter)
app.use("/api/services", servicesRouter)
app.use("/api/customers", customersRouter)
app.use("/api/inventory", inventoryRouter)
app.use("/api/stock-movements", movementsRouter)

Request lifecycle

1

Client sends request

The browser or API client sends an HTTP request to the Next.js frontend or directly to the Express API. For data requests, it includes an Authorization: Bearer <token> header.
2

Next.js frontend

Next.js serves the React UI. Data fetching calls are forwarded to the Express backend at NEXT_PUBLIC_API_URL (default: http://localhost:5000/api).
3

Auth middleware

authMiddleware intercepts the request, extracts the Bearer token, and calls jwt.verify(). On success, it attaches userId and tenantId to the req object and calls next().
4

Route handler + Prisma query

The route handler runs a Prisma query that always includes tenantId: req.tenantId in its where clause, ensuring results are scoped to the authenticated tenant.
5

PostgreSQL returns data

Prisma executes the parameterised SQL against PostgreSQL and returns only rows matching the tenant.
6

Response

The route handler serialises the result as JSON and sends it back through Express → Next.js → browser.

Frontend authentication

The client-side session is managed by Supabase Auth. The AuthProvider component wraps the entire application and tracks the current session:
lib/auth-context.tsx
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<SupabaseUser | null>(null)
  const [loading, setLoading] = useState(true)
  const router = useRouter()

  useEffect(() => {
    const supabase = createClient()

    const checkUser = async () => {
      const { data: { session } } = await supabase.auth.getSession()
      setUser(session?.user ?? null)
      setLoading(false)
    }

    checkUser()

    const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null)
    })

    return () => subscription?.unsubscribe()
  }, [])
  // ...
}
The Next.js middleware (middleware.ts) calls updateSession on every request to keep the Supabase session fresh, excluding static assets. For direct API calls to the Express backend, the JWT returned at login is stored in localStorage and attached as the Authorization header on each request.

Build docs developers (and LLMs) love