Tavern Log
Personal project — liveWhy I built it
I play a lot of tabletop RPGs, and over the years I've accumulated a cast of characters I care about — each with their own backstory, voice, and visual identity. I wanted a place to bring them to life beyond character sheets and scattered Google Docs. I wanted something with a more personal touch — a dedicated profile for each character that could really showcase their personality through stories, voice lines, artwork, and a timeline of key moments.
It also doubled as a deliberate full-stack learning project. I had identified specific skill gaps I wanted to fill — Fastify, Prisma, PostgreSQL from scratch, JWT auth, AWS S3, Docker, and GitHub Actions — and designed the architecture to cover all of them in a single project I'd actually want to use.
Features
Character profiles
Each character gets a dedicated profile page with a bio, personality section, status badge, and tags. The landing page surfaces all public characters on the platform with client-side filtering by name, game system, and tag.
Stories
Short stories and narrative pieces written for each character. The admin side uses a Tiptap rich text editor with formatting controls; public pages render the stored HTML through a DOMPurify sanitisation pass. Stories support a draft/publish toggle — drafts are never visible on public pages.
Voice lines
Audio recordings with transcripts and optional context notes. Uploaded directly to S3 via presigned URLs, played back through a custom inline audio player on the public profile.
Art gallery
An image gallery for character artwork, each with an optional title, caption, and artist credit. Images open in a fullscreen lightbox. Like voice lines, images upload directly to S3 from the browser.
Timeline
A chronological list of key moments in the character's story — each with a title, date label, and description. Manually ordered via the admin interface.
Per-character theming
Every character can have its own visual identity. The admin selects a colour theme that controls the background, text, and accent colours across the entire profile — plus a background pattern and page transition animation. Theming is powered by CSS custom properties wired into Tailwind's colour tokens, so the classes stay static while the values change per character at runtime.
Multi-user & admin
Anyone can register an account and create characters. Each user gets a full admin interface with CRUD for all content types — stories, voice lines, artwork, and timeline events — scoped to their own characters. Authentication uses JWT tokens stored in httpOnly cookies with CSRF protection.
Technical details
Stack
Architecture
Monorepo with strict frontend/backend split. The repo is an npm workspaces monorepo with two apps: apps/web (Next.js) and apps/api (Fastify). The API owns all database access via Prisma — the frontend is a pure client that fetches everything over HTTP. This keeps data ownership clean and means auth, CRUD, and file upload logic all live in one place.
RSC prefetch + TanStack Query HydrationBoundary. Public pages fetch data in a React Server Component, dehydrate the TanStack Query cache, and pass it to client components via HydrationBoundary. Client components call useQuery() with the same key and find the data already present — no loading flash, no redundant network request. Admin pages use useMutation() with invalidateQueries() for automatic cache sync after CRUD operations.
Auth from scratch — no NextAuth. Authentication is implemented entirely in Fastify: bcrypt password hashing, JWT signing via @fastify/jwt, httpOnly cookie storage (immune to XSS token theft), and CSRF protection via @fastify/csrf-protection. Next.js middleware reads the cookie to gate /admin/* routes.
Presigned S3 uploads. File uploads go directly from the browser to S3. The client calls an authenticated Fastify endpoint to get a short-lived presigned PUT URL, then uploads the file directly — neither server touches the binary payload.
Per-character theming via CSS custom properties. Tailwind generates static CSS at build time, so dynamically constructed class names from database values get purged. The solution: Tailwind colour tokens reference CSS variables, and the character layout injects actual hex values at runtime via an inline style prop. Static classes, dynamic values.
Notable challenges
Learning three new tools simultaneously under real constraints. Fastify, Prisma, and AWS S3 were all new. Rather than exploring them in isolation, the architecture required integrating them together from the start — Prisma behind a Fastify plugin system, S3 behind an authenticated presign endpoint with CORS-scoped IAM permissions.
TanStack Query RSC hydration pattern. Getting server-fetched data into the client cache without a loading flash required understanding how Next.js App Router server and client rendering phases interact, and how TanStack Query's dehydrate / hydrate cycle bridges them.