Full-stack product · 2026
A multi-tenant reading platform for a publisher
An education publisher needed to put their catalogue online: one platform, many publisher tenants, each with their own branding and their own readers. I built and shipped it end to end, from the Terraform that provisions storage to the page-turn animation in the reader.
- My role
- Sole engineer, from infrastructure to reader UX
- Stack
- Next.js
- TypeScript
- FastAPI
- PostgreSQL
- Alembic
- S3
- Terraform
- Docker
- OAuth
The problem
The publisher wanted their own branded reading site, but the platform had to host more than one publisher without a separate deployment per client. Each tenant needed its own look, its own administrators, and its own readers, from a single codebase.
Books are large PDFs. Readers are often on unremarkable connections. Downloading an entire book before showing page one was not acceptable.
How it fits together
Architecture
reader browser
| \
| \ presigned PUT (large uploads
| \ bypass the edge entirely)
v v
+----------------+ +------------------+
| Next.js | | S3 |
| (Vercel) | | book assets, |
| tenant-aware | | Terraform- |
| routing | | managed CORS |
+----------------+ +------------------+
| ^
| REST | range requests
v | (stream pages)
+--------------------------------+
| FastAPI (Docker, EC2) |
| tenancy, auth, catalogue, |
| per-tenant analytics |
+--------------------------------+
|
v
+----------------+
| PostgreSQL | Alembic migrations
+----------------+What I built
Tenancy as a first-class concern
Publishers are tenants. Branding, accent colour, landing content and administrators all hang off the tenant, and URLs resolve through a generated slug so renaming a publisher does not break existing links. Roles are scoped per tenant, with a super-admin console above them.
A reader that streams
The reader renders pages lazily and streams them from object storage with range requests rather than pulling the whole PDF. Page turns are a real flip animation with swipe and drag, so it reads like a book on a phone rather than a PDF viewer.
Infrastructure as code, deploys on push
Storage and its CORS policy are Terraform-managed rather than configured by hand, so the bucket cannot quietly drift from what the application expects. The API runs containerised with Alembic migrations, and pushing to the release branch deploys.
The 100MB wall
Large book uploads failed at around 100MB. The requests were dying at the CDN edge before ever reaching the application, so no amount of server-side timeout tuning would have fixed it. The fix was to stop proxying uploads at all: the browser now requests a presigned URL and uploads straight to object storage. I wrote it up as a post-mortem so the next person does not re-debug it.
The product
Screenshots are from the real application. Client identifiers and any personal data have been redacted.


Where it landed
- Live and serving the publisher's catalogue on their own domain.
- Nineteen pull requests merged across the application and its Terraform.
- Per-tenant reader analytics so the publisher can see what is actually being read.
- Large-upload failures resolved at the architectural level rather than patched.
