ScholarDeck
Founder & Solo Engineer
Multi-tenant SaaS · Started 2026 · In development
ScholarDeck is my own product, built as two separate Next.js applications in a pnpm monorepo rather than one app split by route group, so the marketing site can never carry the database client. The marketing site is complete and prerenders. The tenant app carries the parts that have to be right before any data exists: hostname-based tenant resolution treated as untrusted input, a hand-built design system, and a pipeline that enforces the boundary between the two apps mechanically instead of by convention. Schema and auth land on top of that.
The problem
Each school reaches the platform at its own subdomain and must see only its own records. The app works out which school to load by reading the Host header of the request, but that header is only text the caller sends, and anyone can set it to anything. The value that decides whose data appears on screen is therefore a value an attacker controls, which makes resolving it a security check rather than string parsing.
Architecture
- Client
- Two Next.js 16 applications on the App Router with React 19, kept separate rather than split by route group. No component library: the primitives are hand-written in a shared package, styled with Tailwind CSS 4 against CSS custom properties.
- State
- Server-rendered throughout, with React state kept local to the components that need it. No client cache layer, because the interface does not fetch at runtime.
- Infra
- Turborepo and pnpm workspaces. GitHub Actions runs format, lint, typecheck, and build, then three custom guards: WCAG contrast on the design tokens, a dependency-tree check, and a secret-pattern grep over the built bundles. Vercel is configured but not provisioned; Supabase CLI drives the local database that has no migrations in it.
Decisions
The subdomain is attacker-controlled input
A school is identified by its hostname, which means the tenant slug arrives in a Host header anyone can set, at the same trust level as a query parameter. Resolution is a synchronous, zero-IO function built from explicit deny rules: exact label counts, a reserved-hostname set, and a rejection for *.vercel.app, which reads like a real tenant host because deployment URLs also carry three labels. Anything unexpected returns null rather than a best guess. The cost is a manual deny list that has to grow with every new platform hostname.
Placeholder company details ship unless something stops them
The marketing site carries an invented registration number, address, and mailboxes, and nothing prevented them going live except remembering to replace them. Gating them behind an environment flag compared against the literal string, so that unset and mistyped both hide, collapses hidden values into TODO strings that the existing placeholder filter already strips at every render site, with no second mechanism and no consumer changed. The accepted cost is real: with the flag unset the deployed site has no contact route at all, and four legal passages had to be rewritten to read correctly without the value.
A lint rule cannot catch a dependency nobody typed
The marketing site must never carry the database client. An import rule catches someone writing that import, but not the package arriving transitively through a helper added to the shared UI package, which is the failure that matters because nobody chose it and nobody would notice. Three layers instead: the lint rule, a script that resolves the full dependency tree and reports the path by which a forbidden package became reachable, and a CI step that greps the built output for secret-key patterns, since source-level checks miss whatever the bundler inlines. The price is two deployments and some duplication between the apps.
Outcomes
- Accessibility gated in CI: every design-token pair checked for WCAG contrast, with the build failing below the threshold
- Cross-app isolation verified against the resolved dependency tree and the built bundle, not just the source
- Marketing site complete end to end, prerendering 16 routes, with draft legal documents marked noindex
Built with
- Next.js
- React
- TypeScript
- Tailwind CSS
- Turborepo
- pnpm
- PostgreSQL
- Supabase
- GitHub Actions
Screens
