Walk into any independent auto workshop in South Africa and you’ll see the same thing: a dog-eared notebook, sticky notes on the monitor, WhatsApp messages as a job tracking system, and invoices typed up in Word. TorqueBooks exists because a workshop management system should be simple, multi-tenant, South African, and free to try.

What You’ll Learn

  • Multi-Tenant Architecture

    How shop_id scoping isolates data at the database level.

  • PocketBase RLS Gotchas

    Real bugs and workarounds from 8 weeks of development.

  • OAuth2 Integration

    Keycloak SSO with case-sensitive field names and silent failures.

  • Schema-as-Code

    Why manual UI configuration doesn’t scale.

The Problem

Walk into any independent auto workshop in South Africa and you’ll see the same thing: a dog-eared notebook, sticky notes on the monitor, WhatsApp messages as a job tracking system, and invoices typed up in Word. Customers don’t know when their car was last serviced. Mechanics don’t know what work is pending. Shop owners don’t know if they’re profitable.

The existing solutions? Over-engineered, US-centric, enterprise-priced garbage that requires a sales call, a demo, and a credit check before you can even see if it works.

TorqueBooks exists because a workshop management system should be:

  • Simple enough that a mechanic with grease on his hands can use it
  • Multi-tenant — one instance serves many shops, each sees only their own data
  • South African — VAT, ZAR currency, local service intervals, WhatsApp nudges
  • Free to try — no sales call, no credit card, just a demo button

What It Looks Like

Before we dive into the build story, here’s what the finished product looks like. Swipe through the carousel to see dashboard, job cards, customer management, and invoices.

The Architecture

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│  React SPA  │────→│  PocketBase  │────→│  SQLite     │
│  (nginx)    │     │  (API + Auth)│     │  (embedded) │
└─────────────┘     └──────────────┘     └─────────────┘
       │                    │
       │                    │
       ▼                    ▼
┌─────────────┐     ┌──────────────┐
│  Keycloak   │     │  Cloudflare  │
│  (SSO)      │     │  (CDN/SSL)   │
└─────────────┘     └──────────────┘

Why PocketBase?

It’s a single binary that bundles a REST API, authentication, file storage, and a SQLite database. No Kubernetes. No microservices. No ORM. One container, one volume, one backup file. For a workshop with 5 mechanics and 200 customers, it’ll never break a sweat.

Why Keycloak?

Workshop owners don’t want to manage yet another set of login credentials. Keycloak provides OAuth2 SSO so they can use their existing NemesisNet identity. Mechanics get invited via token links — no signup form, no friction.

Why React + nginx?

Static files served by nginx with runtime config injection. No SSR complexity. No Node.js in production. The frontend talks directly to PocketBase — no Express backend, no REST middleware, no GraphQL. Just fetch() calls.

Related: This uses the same runtime env injection pattern we covered in our Docker frontend post.

The War Story: 8 Weeks of Development

Week 1: The Prototype

The first version took one weekend. React + Vite, PocketBase with a few collections, basic CRUD. It was ugly. It worked.

Week 2: Multi-Tenant Realization

Without multi-tenancy, every workshop needs their own instance. That means separate databases, separate backups, separate domains. Multi-tenancy with shop_id scoping means one instance, one backup, one domain. Every record gets a shop_id. Every API query filters by @request.auth.shop_id. Every RLS rule enforces it.

PocketBase gotcha: Version 0.37.5 had a bug where @request.auth.record.field didn’t work for user tokens. Upgrading to 0.38.2 fixed it — but introduced a new syntax: @request.auth.field (no .record.).

Week 3: The RLS Nightmare

Row-Level Security in PocketBase is powerful but opaque. Rules that look correct silently return empty results. The debug cycle is: write a rule → test → get zero records → stare at it → realize the syntax is wrong → fix → test → works.

We went through this for every collection. Twice.

Week 4: The OAuth2 Abyss

Configuring PocketBase’s OAuth2 provider for Keycloak should be straightforward. It wasn’t.

Lessons Learned

  • Field names are case-sensitive: authURL, tokenURL, userInfoURL (uppercase URL), not authUrl. Using lowercase silently fails.
  • clientSecret has omitempty: Omitting it from a PATCH request overwrites the stored secret with an empty string. Always include it.
  • mappedFields.name must be set: An empty mappedFields causes OAuth2 user creation to fail with “name: cannot be blank” — a generic 400 with no helpful error.
  • createRule must be empty: PB 0.38.2’s OAuth2 record creation has no auth context. The users collection’s createRule must be "", or signup fails with “Failed to create record.”

Week 5: The sort=-created Bug

About halfway through, sorting by -created started returning 400 errors on every collection. The error message? “Something went wrong while processing your request.” Thanks, PocketBase.

After a day of debugging, the fix was trivial: use sort=-id instead. PB IDs encode the creation timestamp, so sorting by ID descending gives the same result.

Week 6: The Duplicate Records Mystery

Jobs were showing “Unassigned” for the mechanic name even when a mechanic was assigned. The root cause: PB’s PATCH response doesn’t include expand data. After updating a job, the frontend showed stale data until the next page load.

Lesson: Always Re-fetch After Update

Always re-fetch the record with expand after every update. A simple lesson, but one that caused hours of head-scratching.

Week 7: The Invite System

The join-by-invite flow was conceptually simple but had a subtle RLS trap: unauthenticated users need to read invites by token. The invites collection’s listRule and viewRule must be "" (public). Any restriction blocks the join page from fetching the invite.

Week 8: Production Deployment

Production PocketBase was already running with test data. The problem: no schema management. Collections were created manually through the admin UI, relations were added by trial and error, RLS rules were copy-pasted and often wrong.

The Fix: Schema-as-Code

scripts/setup-pb.mjs became the single source of truth. It creates all 9 collections with explicit autodate fields, 14 relation fields, the Keycloak OIDC provider, and RLS rules for every collection. Running it against a fresh PB instance takes 30 seconds.

node scripts/setup-pb.mjs && node scripts/seed-demo.mjs

The Schema: 9 Collections

Collection Type Purpose
shops base Workshop profiles — name, address, VAT, invoice prefix
users auth Staff accounts — role-based access (superadmin > shop_owner > clerk > mechanic)
customers base Vehicle owners — name, phone, email, notes
vehicles base Customer vehicles — make, model, registration, VIN, service intervals
jobs base Job cards — status lifecycle (pending → in_progress → completed → invoiced)
job_parts base Line items on jobs — cascading delete with parent job
invoices base Billing — draft/sent/paid lifecycle with due dates and overdue detection
invites base Workshop join tokens — email + role + 32-char token
waitlist base Pre-launch signups — name, email, workshop name, plan

Every record is scoped to a shop_id. Every API query filters by it. Every RLS rule enforces it. Multi-tenant isolation at the database level.

The Tech Stack

Layer Choice Why
Frontend React 19 + Vite + Tailwind 4 Familiar, fast builds, utility-first CSS
Backend PocketBase 0.38.2 Single binary, embedded SQLite, REST API, file storage
Auth Keycloak 26.1 Centralized SSO, OIDC, no password management
PDF @react-pdf/renderer React components → PDF invoices
State Zustand 5 Minimal boilerplate, no context wrapping
Forms React Hook Form 7 Performant forms with validation
Animations Framer Motion 12 Polish without bloat
Icons Lucide React Clean, consistent icon set
Container Docker + nginx Production builds under 2MB, runtime config injection

The Demo

Visit torquebooks.nemesisnet.co.za and click “Try Demo” — or log in with:

The demo includes a seeded workshop with 5 customers, 7 vehicles, 8 jobs spanning 3 months, 21 job parts across 5 jobs, and 3 invoices in various states (draft, sent, paid). Three vehicles are overdue for service — visible on the dashboard with months-since-service counts.

What’s Next

  • WhatsApp/email service reminders — Automated nudges for overdue services
  • Email delivery — SMTP integration for invoice sending
  • Real-time subscriptions — Live dashboard updates via PB’s real-time API
  • Parts inventory — Stock tracking with auto-decrement on job completion

Key Lessons Summary

  1. Multi-tenancy from day one — Add shop_id to every collection before writing a single query.
  2. Schema-as-code is essential — Manual UI configuration doesn’t scale. Script it.
  3. PocketBase RLS is opaque — Test every rule with every role. Silent failures are the norm.
  4. OAuth2 field names are case-sensitiveauthURL not authUrl. Go struct tags don’t forgive.
  5. Always re-fetch after PATCH — PB’s PATCH response doesn’t include expand data.

Conclusion: Build for Your Market

TorqueBooks isn’t trying to compete with enterprise workshop management software. It’s trying to replace the notebook and the WhatsApp group with something that actually works — for South African workshops, at South African prices, with South African features like VAT and WhatsApp nudges.

The tech stack is deliberately simple: PocketBase, React, nginx, Keycloak. No microservices. No Kubernetes. No GraphQL. One container, one volume, one backup. For a workshop with 5 mechanics, that’s all you need.

If you’re building a SaaS for a specific market, start with the simplest possible stack and add complexity only when you need it. PocketBase got us from zero to production in 8 weeks — bugs and all.

Need Help Building Your SaaS?

We design and deploy multi-tenant applications for South African businesses. Whether you need a workshop management system, a booking platform, or a custom SaaS, our team can help you build something that actually works.

Related Reading