niso.moe
  • About
  • Projects
  • Blog
  • Contact
Back to Overview

© 2026 niso.moe

  • Privacy
  • Imprint

Plushies

September 23, 2026
Tags:Front-EndBack-EndFull-StackWebsite
Technologies:NextJSTypeScriptTaildwind CSSshadcn/uiWebsockets
Visit WebsiteGitView Source

A cozy website for my plushie collection, where each plushie has its own page with a name, birthday, favorite things and a photo gallery. Visitors can sign in to like plushies and leave comments, and a dashboard lets editors and admins look after the collection and the community.

Stack: Next.js 16 (App Router, server actions), React 19, TypeScript, Tailwind CSS 4, shadcn/ui, Prisma on PostgreSQL, Better Auth, UploadThing, Resend, Bun

#What it does

  • Plushie pages with a thumbnail, a photo gallery and details such as species, pronouns and a birthday that can be as vague as just a year.
  • Likes and threaded comments. Replies nest as deep as people go. Comments can be edited and deleted, and they have a length limit, a rate limit and a filter that blocks links.
  • Accounts with email and password, Discord, or passkeys. Password sign-ins can use two-step codes from an authenticator app or by email, with backup codes.
  • Three roles. Users look, like and comment. Editors also manage plushies and moderate comments. Admins also manage people.
  • A dashboard with an overview, lists you can search, sort and filter, a photolibrary, a comment moderation page, upcoming birthdays and photo storage usage.
  • An activity log that records every change to plushies, accounts and moderated comments, and lets you revert most of them with one click.
  • Admin tools: changing roles, time-limited bans with reasons, signing someone out everywhere, sending them a password reset, and viewing the site as them.
  • Light and dark themes in pink and purple, with link previews and icons generated from the same colors.

#Things I'm proud of

Reverts that can't destroy newer work. Each activity entry stores the relevant fields from before and after the change. A revert is only offered while the current state still matches the entry's "after". If anything has changed since, the button goes away instead of silently overwriting that change.

Security at every step. Deleting an account or changing a verified email needs a link from the current inbox, so a stolen session isn't enough. Discord tokens are encrypted at rest. Sign-in codes are stored only as hashes and have their own rate limit. Password reset requests take the same time whether or not the email exists. Viewing the site as someone else is strictly read-only, enforced on the server. The page that tells a banned person why they were banned only opens through a signed, short-lived cookie, so nobody else can look up someone's ban.

Guardrails for admins. The last admin can't delete their account and leave the site without one. Admins can't be banned. The auth library's own admin endpoints are narrowed to only what the site actually offers.

Tests against a real database. 75 tests cover plushies and photos, comments, likes, user management, bans, reverts, two-step sign-in and account settings. Each run starts a throwaway PostgreSQL container, applies the real migrations and removes the container afterwards. The test setup refuses to run against any database other than a local one, emails are recorded instead of sent, and file uploads go to a pretend store.

Polish. The loading placeholders match the shape of the real pages. Empty states are designed, not blank. Forms keep what you typed when saving fails. Text in menus is trimmed to cap height so it sits centered.

#What I learned

  • Designing permissions as data (roles mapped to allowed actions) and checking them on the server in every action, not just hiding buttons.
  • Modeling threaded comments in Postgres so a page can load whole threads in one query, and keeping deleted comments as "[deleted]" while they still have replies.
  • Writing database migrations for a live schema that kept growing, from plushies to auth, bans, comments, two-step sign-in and passkeys.
  • Building an audit log that outlives what it describes, by storing names and snapshots instead of relying on relations.