A multilingual community platform for niche hobby groups and small creative communities — the kind of intimate, cross-cultural spaces mainstream social platforms underserve. Discussion boards, event coordination, and per-post language tagging, with inclusive design throughout.
Stack: Next.js (App Router) · TypeScript · PostgreSQL · Prisma · Supabase (Auth + Storage) · CSS design system
Two systems with a clean division of labor, chosen deliberately:
| Concern | Owner |
|---|---|
| Schema + migrations | Prisma (one source of truth in schema.prisma) |
| Typed queries | Prisma |
| Auth (signup, sessions, JWTs) | Supabase Auth |
| File/resource storage | Supabase Storage |
| Tenant isolation + roles | App layer (requireMembership guard) |
The seam between them: a Supabase auth user is mirrored into Prisma's User
table by a database trigger (supabase/migrations/0001_mirror_auth_users.sql).
Prisma owns the table's shape; the trigger only populates it.
Every community-scoped request runs the same chain:
middleware (refresh session)
→ requireUser() verified identity, unspoofable (getUser, not getSession)
→ requireMembership() membership + role check against the resolved community
→ Prisma query data the viewer is allowed to see
The rule that makes it safe: never authorize against a communityId from the client. Always resolve the resource (board → community) server-side, then check membership against that. Route params that drive authorization are bound into server actions on the server, so they never cross the client boundary.
npm install(postinstall runs prisma generate automatically.)
cp .env.example .envFill in the four values from your Supabase dashboard (Project Settings → API
for the first two, → Database for the connection strings). Use the pooler
URL (port 6543) for DATABASE_URL and the direct URL (port 5432) for
DIRECT_URL.
npx prisma migrate dev --name initThis creates all tables (User, Community, Membership, Board, Post,
Event, Resource).
Run supabase/migrations/0001_mirror_auth_users.sql in the Supabase SQL editor
(or via the Supabase CLI). Order matters — the User table must exist
first (step 3), because the trigger inserts into it.
npm run devA page needs all of these to render live data:
-
.envfilled with real Supabase + database values -
prisma migrate devrun (tables exist) - signup trigger installed (new users get a
Userrow) - at least one community created (visit
/newonce signed in)
Auth pages (/login, /signup) are not included in this slice — wire them with
Supabase Auth UI or your own forms calling supabase.auth.signUp /
signInWithPassword. Once a session exists, the whole app works.
| Path | What |
|---|---|
/new |
Create a community (any signed-in user) |
/c/[slug] |
Community lobby — boards + next event |
/c/[slug]/boards/new |
Create a board (MOD+) |
/c/[slug]/boards/[boardId] |
A board — read + inline compose |
/c/[slug]/boards/[boardId]/new |
Full-page post composer with language picker |
/c/[slug]/events |
Events — upcoming + past |
| Decision | Rationale |
|---|---|
| Prisma owns schema, not Supabase RLS | Multi-tenant access logic (membership × role × privacy) is clearer and more testable in TypeScript than stacked SQL policies; Prisma bypasses RLS anyway. |
Role lives on Membership, not User |
A user can be OWNER of one community and MEMBER of another — role is per-tenant. |
| DB trigger mirrors auth users (not app code) | Fires on every signup path (OAuth, magic link, dashboard), not just one code path that a bug could skip. |
getUser() everywhere, never getSession() |
getSession() only decodes the cookie locally; a forged cookie passes. getUser() verifies against Supabase. Non-negotiable for authz. |
post.locale per post |
The multilingual core: every post carries the language it was written in, enabling future translate/filter features. |
| Private boards reuse the role hierarchy | A private board is just "same membership check, higher minRole" — one ternary, no new permission system. |
- The Prisma/Supabase overlap is real and has to be resolved on purpose, not drifted into. Deciding who owns the schema up front prevented two competing sources of truth.
- Authorization should shape the query, not just the render — filtering
private boards in the
whereclause means they never leave the database for someone who shouldn't see them, rather than being fetched and hidden in JS. - Avoiding N+1 in the lobby (latest post + count per board) came down to
_countand a nestedtake: 1relation in a singlefindMany.
- Add a
Translationtable for user-content translation (deferred as a v2 — it needs either a translation service or user-submitted translations with their own moderation). - Realtime board updates via Supabase Realtime, so new posts appear without a refresh.
- Rate limiting on the create actions.