Skip to content

Repository files navigation

🏮 Lantern

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.

image

Stack: Next.js (App Router) · TypeScript · PostgreSQL · Prisma · Supabase (Auth + Storage) · CSS design system


Architecture at a glance

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.

The authorization spine

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.


Setup

1. Install

npm install

(postinstall runs prisma generate automatically.)

2. Configure environment

cp .env.example .env

Fill 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.

3. Create the schema

npx prisma migrate dev --name init

This creates all tables (User, Community, Membership, Board, Post, Event, Resource).

4. Install the signup trigger

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.

5. Run

npm run dev

Boot checklist

A page needs all of these to render live data:

  • .env filled with real Supabase + database values
  • prisma migrate dev run (tables exist)
  • signup trigger installed (new users get a User row)
  • at least one community created (visit /new once 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.


Routes

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

Decisions

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.

What I learned

  • 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 where clause 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 _count and a nested take: 1 relation in a single findMany.

If I built it again

  • Add a Translation table 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.

About

A multilingual community platform for niche hobby groups and small creative communities

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages