All blueprints
Internal Platform

Internal SaaS Platform. One stack. Many modules.

The reference architecture I use for any internal platform — HR, ops, sample tracking, asset management, internal CRM. Single Next.js app, single Postgres, RLS for authorisation, a small set of conventions that scale across modules without microservice overhead.

Components

Next.js (App Router)
Single application, server components by default, server actions for mutations.
Supabase Postgres
Primary data store. Real Postgres, no abstraction. One project, many schemas.
Supabase Auth
JWT-based auth, OAuth providers, magic-link invites for new staff.
Row-Level Security
Authorisation lives at the database layer, not the API layer.
Supabase Storage
Object storage for receipts, payslips, and other documents. RLS policies over buckets.
Vercel Cron
Scheduled jobs for digests, overdue alerts, monthly exports.
Resend + React Email
Transactional email, with templates living in the same codebase.
shadcn/ui + Tailwind
UI primitives you own, consistent across modules.
Sentry
Error tracking on both client and server, wired up from day one.

When to use this

  • Building an internal platform for 10–500 internal users
  • Multiple business modules (leave, expenses, attendance, etc.) sharing one user base
  • Solo or small team — you do not have an SRE function
  • Authorisation is role-based and you have a finite set of roles
  • You want to ship modules quickly, with shared conventions

When not to use this

  • ×Multi-tenant public SaaS — see the multi-tenant blueprint instead
  • ×Workloads that genuinely need horizontal scaling beyond a single Postgres
  • ×Compliance regimes that prohibit shared infrastructure (some regulated verticals)
  • ×Large engineering orgs where module ownership genuinely needs to be split across teams and services

Schema convention

I use a single Postgres database with one schema per module — `hr`, `expenses`, `samples`, etc. — plus a shared `auth` schema (Supabase) and a shared `app` schema for cross-module concerns like profiles and roles.

Each module schema is self-contained: its own tables, its own RLS policies, its own views. Cross-module joins go through the `app` schema. This keeps modules legible and makes it easy to retire a module without touching the others.

-- Each module owns its own schema
CREATE SCHEMA hr;
CREATE SCHEMA expenses;
CREATE SCHEMA samples;

-- Shared cross-module concerns live in app
CREATE SCHEMA app;
CREATE TABLE app.profiles (
  id uuid PRIMARY KEY REFERENCES auth.users(id),
  full_name text NOT NULL,
  role text NOT NULL CHECK (role IN ('staff','manager','director','admin')),
  created_at timestamptz DEFAULT now()
);
ALTER TABLE app.profiles ENABLE ROW LEVEL SECURITY;

Authorisation convention

Every table that holds user data has RLS enabled. Policies are written using helper functions that read the user’s role from the JWT. The pattern is consistent across modules, so a developer who has read one module can read any of them.

-- Helper: current user's role from app.profiles
CREATE FUNCTION app.current_role() RETURNS text AS $$
  SELECT role FROM app.profiles WHERE id = auth.uid()
$$ LANGUAGE sql STABLE;

-- Per-table RLS policies follow a consistent shape
CREATE POLICY "staff_can_read_own"
  ON expenses.claims FOR SELECT
  USING (claimant_id = auth.uid());

CREATE POLICY "managers_can_read_team"
  ON expenses.claims FOR SELECT
  USING (app.current_role() IN ('manager','director','admin'));

UI convention

Each module gets a route segment under `app/(modules)/<module>` with its own layout. Shared navigation lives at the platform level. A module can register itself with the global navigation by exporting a manifest from `app/(modules)/<module>/manifest.ts`.

New modules slot into the navigation automatically. The convention removes a class of bug — &ldquo;I added the module but forgot to add it to the menu&rdquo; — and makes onboarding a new module a five-minute job.

Cross-cutting concerns

  • Email — Resend client wrapped in a single typed sender, with React Email templates per email type.
  • Cron — Vercel Cron routes under `app/api/cron/*`, each authenticated via a shared secret, idempotent by design.
  • Audit log — every server action that mutates data writes a row to `app.audit_log` with actor, action, target, payload diff.
  • Observability — Sentry client + server, with a single `captureException` wrapper that adds tenant context automatically.

Deployment shape

One Vercel project, one Supabase project, one production environment, one staging environment. Branch deploys preview against the staging Supabase. Production deploys are gated on green CI plus a manual promote.

Migrations are SQL files run via the Supabase CLI. A small `migrate.ts` script runs in CI to ensure migrations are applied to staging on every merge. Production migrations are applied manually, ahead of the release that depends on them.

Alternatives I considered

Hand-rolled Postgres + Express + Auth0

More flexibility, much more wiring. The integration tax across auth, storage, and realtime is significant. Worth it only at scale where you need vendor independence.

Firebase

Excellent for prototypes, painful at module-platform scale. Schemaless data tends to compound complexity over years. Rule language is not as expressive as RLS.

Microservices per module

Misleadingly fashionable. For an internal platform, the operational cost of distributed systems vastly exceeds the coordination benefit. A modular monolith wins almost every time.

Off-the-shelf low-code platforms

Faster to start, slower to evolve. Lock-in escalates as soon as you need real customisation. Acceptable for the first two modules; painful by the fifth.

Want me to build this for you?

Blueprints are how I think. If your problem fits one of these, we are already most of the way to a quote.