Designing a 17-model social platform without drowning in joins
Notes from building Garage Hub — a Next.js 16 + Prisma 7 social platform for car enthusiasts. Five product surfaces, one PostgreSQL, and the domain model that had to hold them all together without turning every feed query into a twenty-table join.
The project
Garage Hub is a personal project I kept pushing on because the shape of the problem was interesting: a community platform that isn't just a feed. Car enthusiasts want five separate things in one place — a social feed, a place to document their cars and mod histories, events and meetups, club membership, and a parts marketplace. Each of those is a whole product on its own. The engineering challenge wasn't any single surface; it was stitching all of them into one coherent data model and one authenticated session without the codebase starting to smell.
The stack I settled on:
- Next.js 16 with the App Router, using route handlers for the REST API — one deployable, one auth story, server components where they help.
- Prisma 7 against PostgreSQL, via the
@prisma/adapter-pgadapter. Typed queries, migration history in git. - JWT auth — bcryptjs for hashing, httpOnly cookies for transport. No third-party identity service; this had to run from a single repo with a single
DATABASE_URLand aJWT_SECRET. - shadcn/ui + Base UI + Tailwind 4 on the frontend,
react-dropzonefor uploads,react-markdownwith GFM for post rendering.
Why five surfaces in one schema
The obvious alternative was microservices — a posts service, a cars service, a marketplace service, each with its own database. I rejected it for the same reason most solo or small-team projects should: the cost of distributed state enormously outweighs the benefit of clean bounded contexts until you're at a scale that a personal project will never see. One Postgres, one migration timeline, one schema that has to be thought about carefully.
The real question was how to model the seams. A post that references a car. A marketplace listing that denormalizes vehicle data. A notification that might point at a post, a comment, an event, or a club. Those cross-surface references are where a naive schema turns into spaghetti.
The domain model
Seventeen Prisma models, grouped by product surface. A few are worth walking through because they show the decisions that matter.
User, and the self-referential Follow
The User model owns a fan-out of relations into every other surface — cars, posts, RSVPs, club memberships, messages, listings. The follow graph is a self-referential Follow table with followerId and followingId columns and a composite unique constraint on the pair. That unique constraint is the only thing keeping the follow graph from growing duplicates under a retry, and it's worth more than any application-level check.
Post and its typed content
Posts carry a postType enum — GENERAL, BUILD_UPDATE, DYNO_RESULT, PHOTO — and an optional carId reference. Dyno results include horsepower, torque, and RPM columns directly on the post row. That denormalization was deliberate: most posts don't have dyno data, and the alternative — a separate DynoResult table with a nullable one-to-one — costs an extra join on every feed query to render the badge. Rows are cheap; joins in a hot path aren't.
Notification as polymorphic pointer
Notifications have a type enum and four nullable reference columns — postId, commentId, eventId, clubId. Exactly one is populated, chosen by the type. This is the sparse-column flavor of a polymorphic association. The alternative — a single entityType/entityId pair — sacrifices foreign-key integrity. Sparse columns keep the cascading deletes honest, and Postgres doesn't care about a few nullable columns. A compound index on (userId, read) makes the unread-count query cheap.
Marketplace, with deliberate denormalization
Marketplace listings carry carMake, carModel, and carYear as string/integer fields on the listing itself rather than joining to the Car table. Sellers list parts that fit cars they don't own, and search has to work across makes and models whether or not the seller registered the vehicle. Denormalization here reflects the product, not laziness. Indexes on status, seller, and category do the heavy lifting for filters.
RSVPs, likes, memberships — all composite-unique
Four models ( Like, Follow, RSVP, ClubMembership) enforce a composite unique constraint on the natural key — "one user can like a post once", "one user can RSVP an event once". The database, not the API, is the source of truth for these invariants. Any retry storm or racing request hits the unique index and fails cleanly instead of producing two rows I later have to deduplicate.
The index strategy
Seventeen models, eleven API groups, and every feed is a list that needs to paginate by createdAt desc. The indexing rules I landed on:
- Every feed-like table indexes its ordering column. Posts, events, club posts, build updates, messages. A descending index on
createdAtmakes feed pagination an index scan, not a sort. - Every user-scoped feed gets a compound index.
(userId, createdAt)for a user's own posts,(carId, createdAt)for a car's build history,(clubId, createdAt)for a club's feed. - Filtered counts get their own compound index. The unread-message counter indexes
(receiverId, read). The unread-notification badge indexes(userId, read). Without those, every page load does a sequential scan on the table that grows fastest.
Auth without a third party
JWT in httpOnly cookies, bcryptjs for hashing. Boring by design. The interesting decision was keeping auth in the monolith rather than reaching for Auth0 or NextAuth. Reasoning: the app owns user records, usernames, follow relationships, and cascading deletes on user removal. Delegating identity to an external service would turn every one of those into a synchronization problem. The cost of a few dozen lines of JWT sign-and-verify is less than the cost of two systems of record for the same user.
Cascading deletes on the user do get aggressive — drop a user and their cars, posts, comments, likes, and memberships go with them. That's intentional: GDPR-style hard deletes should work from one DELETE, not from an orchestration script.
What I'd do differently
- Pick the feed query shape earlier. The "home feed" — posts from people you follow, in reverse chronological order — is a fan-out problem that every social platform eventually hits. Doing it as a live query over
Follow+Postworks at low scale and dies gracelessly at higher scale. I didn't need fan-out-on-write here, but I'd at least sketch the migration path before the schema freezes. - Put soft delete on the content tables. Posts, comments, and listings benefit from a
deletedAtcolumn even when users don't need restore. Moderation and abuse recovery are so much cheaper with a soft-delete column than with a real purge. - Seed the database with enough data to stress-test. The existing seed script covers all 17 models with demo data. Useful for a demo; useless for finding the query that sequential-scans a 10k-row table. A next iteration would ship a load generator alongside the seed, parameterized by user count.
Closing
The part of this project I think about most isn't any single feature — it's how many product decisions turn into schema decisions. An enum instead of a lookup table. A denormalized column to skip a join. A composite unique to enforce an invariant the API would otherwise forget. A polymorphic pointer shaped as sparse foreign keys so cascades still work.
Senior full-stack work is, in large part, deciding where complexity lives. In Garage Hub most of it lives in the schema, on purpose. The routes are thin, the validators are boring, and the product features read like SELECTs with filters. That's the shape you want.
- Framework
- Next.js 16 (App Router), React 19, TypeScript
- Database
- PostgreSQL via Prisma 7 (@prisma/adapter-pg)
- Auth
- JWT in httpOnly cookies, bcryptjs hashing
- UI
- shadcn/ui, Base UI, Tailwind 4, react-markdown (GFM)
- Uploads
- react-dropzone + route handlers for media ingest
- API surface
- REST handlers: auth, cars, clubs, conversations, events, marketplace, notifications, posts, search, upload, users
- Schema scale
- 17 Prisma models across 5 product surfaces
- Code
- github.com/raimieltan/garage-hub
Hiring for a senior full-stack role?
I'm currently open to senior / staff backend and full-stack roles, remote across time zones.