StaffPortal
A complete open-source HR platform — attendance, leave, expenses, kiosk, visitors — built on Next.js and Supabase.
Abstract
StaffPortal is an open-source, MIT-licensed staff management platform that consolidates attendance, timesheets, leave, expenses with AI receipt scanning, kiosk sign-in, visitor management, announcements, and analytics into a single Next.js 16 application backed by Supabase. This whitepaper documents the multi-tenant security model (Row-Level Security on every table), the domain models for attendance and leave, the kiosk-mode offline architecture, the embedded receipt OCR pipeline, payroll-ready export contracts, and the deployment topology that runs the platform for £20 per month across a 50-person organisation.
01Executive Summary
StaffPortal replaces per-seat HR SaaS for small-to-medium organisations. Eight cohesive modules — attendance, timesheets, leave, expenses, kiosk, visitors, announcements, analytics — share a single Next.js application, a single Supabase project, and a single set of authentication, authorisation, and notification primitives. The data layer is a 40-table PostgreSQL schema with Row-Level Security on every row, scoped by both user_id and organisation_id.
Attendance is captured through three channels: the web application for office staff, a tablet-mounted kiosk for on-site staff, and a manager-side bulk-edit view for retroactive corrections. The kiosk mode runs as a Progressive Web Application, captures sign-in photos, works offline, and synchronises on reconnect. Leave management implements per-employee allowances with rolling-year accrual, manager approval flows, and a team calendar with conflict detection.
Expense capture embeds the Receipt Scanner pipeline directly: an employee photographs a receipt, the image passes through Anthropic Claude vision OCR, returns structured JSON, and enters a manager approval workflow with policy checks. Payroll-ready exports produce CSV files compatible with Xero, QuickBooks, and Sage at the close of each pay period.
02Background & Motivation
The HR SaaS market — BambooHR, Personio, Hibob, Rippling, Sage HR, Breathe — charges between £8 and £15 per employee per month. For a 30-person business that is between £2,800 and £5,400 a year. For a 100-person business, between £9,600 and £18,000 a year. Every year. For software that mostly tracks who is in, who is on holiday, and who claimed lunch on expenses.
The functionality these products provide is not technically complex. It is a relational schema, some forms, an approval workflow, a tablet kiosk, and an OCR pipeline. The reason businesses pay rather than build is that no credible open-source alternative exists. The available open-source HR projects are either single-feature (clock-in only, leave-only) or built on legacy stacks (LAMP, custom PHP) that do not appeal to teams used to modern web frameworks.
StaffPortal exists to fill that gap with a project built on the stack most contemporary developers already use — Next.js, TypeScript, Supabase, Tailwind — and licensed permissively enough to be deployed inside any organisation without legal review.
03The Problem
The specific problems StaffPortal solves:
- Per-seat pricing. Every employee added to BambooHR is another £8 to £15 per month forever. StaffPortal’s cost is fixed at the infrastructure level — Supabase free tier covers up to 500MB of HR data, Vercel hosts the app, the only variable cost is Anthropic vision OCR for receipts.
- Data exit. Closed HR platforms make data export awkward (or paid). StaffPortal stores everything in your own Supabase project; you have direct SQL access at all times.
- Customisation requires "Enterprise" tier. Adding a custom expense category in BambooHR may require an upgrade. StaffPortal lets you add a column and a form field with a single migration.
- Integration latency. Closed platforms expose webhooks and APIs at a paid tier. StaffPortal is the database — integrations are SQL queries or Supabase Edge Functions.
04Goals & Non-goals
Goals
- Replace BambooHR / Personio for organisations of 5 to 200 employees.
- Self-host on Vercel + Supabase free tier for organisations under 50 staff.
- Multi-tenant by default — one deployment can serve multiple organisations.
- Row-Level Security enforced at PostgreSQL on every table.
- Payroll-ready CSV exports for Xero, QuickBooks, Sage.
- Kiosk mode that works offline and synchronises on reconnect.
Non-goals (v1)
- Performance reviews and OKRs. Different shape of problem; out of scope for the first major release.
- Recruitment / ATS. Distinct domain. Belongs in a sibling project.
- Direct payroll execution. StaffPortal exports the data; running payroll is the job of HMRC-recognised payroll software.
- Mobile-native apps. The PWA covers the kiosk and on-the-go use cases. Native iOS/Android is roadmap, not v1.
05Architecture
System overview
┌──────────── BROWSER ─────────────┐ ┌───── KIOSK TABLET (PWA) ─────┐
│ Employee · Manager · Admin │ │ PIN auth · photo capture │
│ web routes (server-rendered) │ │ offline-capable IndexedDB │
└──────────────────────────────────┘ └──────────────────────────────┘
│ │
└──────────── Supabase auth ─────────┘
│
▼
┌──────────── NEXT.JS 16 APP (Vercel) ───────────────────────────────┐
│ /api/attendance · /api/leave · /api/expenses · /api/visitors │
│ /api/kiosk · /api/announcements · /api/analytics │
│ shared lib: auth · permissions · notify · audit · scanner · cron │
└────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌──────────────┐
│ SUPABASE │ │ RESEND │ │ ANTHROPIC │
│ Postgres + │ │ email (notify │ │ Claude 3.5 │
│ Auth + RLS + │ │ approvals, │ │ vision (OCR)│
│ Storage │ │ announce) │ │ │
└────────────────┘ └────────────────┘ └──────────────┘Module map
| Module | Routes | Tables |
|---|---|---|
| Attendance | /attendance, /api/attendance | shifts, clock_events, timesheets, timesheet_lines, schedules |
| Leave | /leave, /api/leave | leave_types, leave_allowances, leave_requests, leave_approvals |
| Expenses | /expenses, /api/expenses, /api/scan | expense_categories, expenses, expense_items, expense_approvals, receipts |
| Kiosk | /kiosk, /api/kiosk | kiosks, kiosk_sessions, sign_in_events |
| Visitors | /visitors, /api/visitors | visitors, visitor_visits, visitor_watchlist, ndas |
| Announcements | /announcements | announcements, announcement_reads, announcement_targets |
| Analytics | /analytics | materialised views over the live tables |
| Notifications | shared lib | notify_preferences, notify_log, slack_webhooks |
06Key Technical Decisions
Why Supabase rather than a custom Postgres + auth stack
Supabase bundles PostgreSQL, an auth service that issues JWTs, Row-Level Security policy enforcement, object storage with signed URLs, and a JavaScript SDK that handles cookie management out of the box. Reproducing that in-house is at least 3,000 lines of code and a permanent operational burden. Supabase is the largest single accelerant in the project.
Why Row-Level Security on every table
Multi-tenancy at the application layer is one programmer error away from data leakage. RLS makes the database refuse to serve organisation A’s data to a session authenticated as organisation B, regardless of what the application code says. (auth.uid(), auth.jwt() -> organisation_id) is in every USING clause.
Why Next.js 16 App Router
Server components keep authorisation logic and database queries server-side, where they cannot be tampered with. The App Router’s file-based routing maps cleanly to module structure. Server Actions reduce form-submission boilerplate.
Why a kiosk Progressive Web Application
Tablet kiosks are deployed across factory floors, warehouses, and on-site teams where Wi-Fi is intermittent. A PWA with IndexedDB-backed offline queue captures sign-ins reliably and synchronises when connectivity returns. The alternative — a native iOS/Android app — multiplies maintenance and submission overhead with no functional benefit for this use case.
Why embed Receipt Scanner directly
Expense capture is the most-used premium feature in HR SaaS, and dedicated mobile receipt scanners (Dext, Pleo, Expensify) charge per user. StaffPortal embeds the same Receipt Scanner pipeline that ships as a standalone product — Claude vision OCR, sharp image preprocessing, Zod-validated JSON, persistence into the expenses tables. Cost: roughly 1 penny per scan.
Why Resend for notifications
Approvals, leave decisions, expense rejections, announcement digests, and visitor host alerts all need transactional email. Resend has the cleanest API, the simplest domain verification, a generous free tier, and SDK-level support for templated HTML emails.
Why payroll-ready CSV rather than direct integrations
Direct payroll integrations (Xero API, QuickBooks API) require OAuth, scope management, and per-vendor field mapping. CSV export is the universal interface every payroll product accepts. Direct integrations are roadmap, not v1.
07Implementation
Multi-tenant RLS pattern
-- Every table has organisation_id and user_id columns.
-- RLS policies scope by both.
create policy "members_can_read_org_data" on expenses
for select
using (
organisation_id = (auth.jwt() ->> 'organisation_id')::uuid
);
create policy "members_can_insert_own_expense" on expenses
for insert
with check (
user_id = auth.uid()
and organisation_id = (auth.jwt() ->> 'organisation_id')::uuid
);
create policy "managers_can_update_team_expense" on expenses
for update
using (
organisation_id = (auth.jwt() ->> 'organisation_id')::uuid
and exists (
select 1 from team_memberships m
where m.user_id = auth.uid()
and m.role in ('manager', 'admin')
and m.team_id = expenses.team_id
)
);Receipt OCR pipeline (embedded)
// app/api/expenses/scan/route.ts
import { extract } from '@/lib/scanner' // shares the Receipt Scanner code
export async function POST(req: Request) {
const user = await requireAuth()
const orgId = await getOrganisationId(user.id)
const form = await req.formData()
const file = form.get('image') as File
const buf = Buffer.from(await file.arrayBuffer())
const receipt = await extract(buf) // Claude vision OCR
const url = await uploadToStorage(buf, user.id) // signed Supabase URL
const { data } = await supabase.from('expenses').insert({
user_id: user.id,
organisation_id: orgId,
vendor: receipt.vendor,
amount: receipt.total,
currency: receipt.currency,
date: receipt.date,
raw: receipt,
image_url: url,
status: 'pending',
}).select('*').single()
await notifyManager(user.id, 'expense.submitted', data)
return Response.json({ ok: true, expense: data })
}Kiosk offline queue
// In the kiosk PWA service worker:
// Sign-in events captured offline are queued in IndexedDB
// and replayed when navigator.onLine returns true.
self.addEventListener('online', async () => {
const queued = await idb.getAll('queued_signins')
for (const evt of queued) {
const res = await fetch('/api/kiosk/sign-in', {
method: 'POST',
body: JSON.stringify(evt),
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) await idb.delete('queued_signins', evt.id)
}
})Leave allowance accrual
A SQL view computes remaining allowance per employee per leave type, accruing month-by-month from the start of their employment year.
create view leave_balances as
select
m.user_id,
m.organisation_id,
t.id as leave_type_id,
a.annual_days * least(months_since_start(m.start_date) / 12.0, 1) as accrued,
coalesce(used.taken, 0) as used,
a.annual_days - coalesce(used.taken, 0) as remaining
from team_memberships m
join leave_types t on t.organisation_id = m.organisation_id
join leave_allowances a on a.user_id = m.user_id and a.leave_type_id = t.id
left join (
select user_id, leave_type_id, sum(days) as taken
from leave_requests
where status = 'approved'
and date_part('year', start_date) = date_part('year', current_date)
group by 1, 2
) used on used.user_id = m.user_id and used.leave_type_id = t.id;08Results & Operations
Reference deployment cost (50-person organisation)
| Service | Tier | Cost / month |
|---|---|---|
| Vercel | Hobby | £0 |
| Supabase | Pro (recommended past 500MB) | £20 |
| Resend | 3,000 emails free | £0 |
| Anthropic vision (~500 receipts) | Pay-as-you-go | ~£6 |
| Total | ~£26 / month |
Compare with BambooHR Pro at £15/employee × 50 employees = £750/month. The break-even point versus self-hosted StaffPortal is somewhere between 2 and 3 employees.
Performance characteristics
| Operation | P50 | P95 |
|---|---|---|
| Web page render (server component) | ~250 ms | ~450 ms |
| Kiosk sign-in (online) | ~180 ms | ~350 ms |
| Receipt scan (Claude vision) | ~1.6 s | ~2.4 s |
| Leave request submission | ~140 ms | ~290 ms |
| Analytics dashboard initial load | ~400 ms | ~700 ms |
09Lessons & Trade-offs
What worked
- RLS first, route logic second. Designing every table’s policies before writing the route handlers eliminated whole classes of authorisation bugs. The database refuses to leak data even if a route forgets to check.
- One Supabase project per deployment. Resisting the urge to split modules across multiple databases kept transactions, foreign keys, and joins simple. Multi-tenancy is a column, not a schema.
- PWA over native for the kiosk. One codebase. No app store. Updates ship instantly. The IndexedDB offline queue handles real-world site conditions reliably.
- Embedding Receipt Scanner. Reusing a published, hardened OCR pipeline rather than reimplementing kept expense capture lean and consistent with the standalone product.
What we got wrong on first pass
- First leave-balance design used a stored snapshot. It drifted from the underlying transactions whenever a request was approved retroactively. Switching to a SQL view computed live from
leave_allowancesandleave_requestseliminated the drift. - Initial kiosk used localStorage, not IndexedDB. localStorage is synchronous and capped at 5 MB, which broke once sign-ins accumulated photos. IndexedDB is the right primitive for queued-with-blob workloads.
- Approvals originally lived as a separate table per workflow. Five approval tables (leave, expense, timesheet, visitor, announcement) duplicated logic. Consolidating into a generic
approvalstable with a polymorphictarget_typehalved the route code.
Trade-offs we accept
- Supabase is mostly Postgres but not entirely. Migrating to plain Postgres is possible but loses Auth and RLS-via-JWT. Acceptable lock-in for the value delivered.
- Vercel hobby works under 50 staff. Above that, function timeouts on analytics queries can become an issue. Vercel Pro at £20/month or self-hosting on a VPS resolves it.
- CSV exports rather than direct payroll integrations in v1. Universal but requires manual import. Direct Xero / QuickBooks integrations are on the roadmap.
10Conclusion
StaffPortal demonstrates that a complete HR platform — covering attendance, leave, expenses, kiosk sign-in, and visitor management — fits in a single Next.js 16 application backed by a single Supabase project, costs roughly £26 per month to operate for a 50-person organisation, and can be deployed without writing a custom line of code. The premium that HR SaaS vendors charge is not paying for technically hard work; it is paying for distribution. Open source closes that gap.
The schema is yours. The UI is yours. The notification logic is yours. Customisation is a migration and a form field, not a sales call. That is the proposition.
ASchema highlights
-- Membership ties a user to an organisation with a role
create table team_memberships (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
organisation_id uuid not null references organisations(id) on delete cascade,
team_id uuid references teams(id),
role text not null check (role in ('admin', 'manager', 'member')),
start_date date not null,
end_date date,
created_at timestamptz default now(),
unique (user_id, organisation_id)
);
-- Generic approvals table (polymorphic target)
create table approvals (
id uuid primary key default gen_random_uuid(),
organisation_id uuid not null,
target_type text not null, -- 'leave_request' | 'expense' | 'timesheet'
target_id uuid not null,
approver_id uuid not null references auth.users(id),
status text not null check (status in ('pending', 'approved', 'rejected')),
reason text,
created_at timestamptz default now(),
decided_at timestamptz
);
-- Expenses with embedded raw OCR JSON
create table expenses (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
organisation_id uuid not null references organisations(id) on delete cascade,
team_id uuid references teams(id),
category_id uuid references expense_categories(id),
vendor text,
amount numeric(12,2),
currency text,
date date,
raw jsonb, -- full Receipt Scanner output
image_url text,
status text not null default 'pending',
created_at timestamptz default now()
);
alter table expenses enable row level security;BConfiguration
| Variable | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Yes | Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Yes | Supabase anon key (client-safe) |
SUPABASE_SERVICE_ROLE_KEY | Yes | Service role key (server-only) |
RESEND_API_KEY | Yes | Transactional email delivery |
FROM_EMAIL | Yes | Verified sender domain |
ANTHROPIC_API_KEY | Yes | Receipt OCR vision API |
SLACK_WEBHOOK_URL | No | Optional Slack fan-out for visitor and approval alerts |
KIOSK_PIN_LENGTH | No | Default 4 digits |
SIGN_IN_PHOTO_REQUIRED | No | Default false |