尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
A multi-tenant community platform — one app, many communities. Each
community has its own spaces, members, posts, events, and resources.
Built with Next.js (App Router), Tailwind CSS, and Supabase (Auth +
Postgres + Storage).
• Next.js 16 (App Router, Turbopack)
• Tailwind CSS v4
• Supabase — Postgres + Auth (@supabase/ssr, @supabase/supabase-js)
• Vercel for deployment查看完整 README ↗A multi-tenant community platform — one app, many communities. Each community has its own spaces, members, posts, events, and resources. Built with Next.js (App Router), Tailwind CSS, and Supabase (Auth + Postgres + Storage).
@supabase/ssr, @supabase/supabase-js)Every file under supabase/migrations/ is a standard Supabase CLI
migration — timestamp-prefixed, applied in filename order, each one
idempotent (if not exists / or replace / on conflict) so re-running
the full set against a database that already has some of them is safe.
On every push to main that touches supabase/migrations/**,
.github/workflows/deploy-supabase-migrations.yml runs supabase link +
supabase db push automatically against production, using the
SUPABASE_ACCESS_TOKEN / SUPABASE_DB_PASSWORD repo secrets — no manual
SQL-editor step needed once that's set up.
The sections below still refer to each migration by its old short name
(e.g. supabase/schema.sql) since that's the stable, meaningful part —
look for the file under supabase/migrations/ ending in _schema.sql,
_notifications.sql, etc. Adding a new migration: drop a new
timestamp-prefixed file straight into supabase/migrations/ (a later
timestamp than anything already there) rather than a loose file at the
supabase/ root — anything not under migrations/ is invisible to the
pipeline.
supabase/seed.sql is the one exception: it's demo/bootstrap data tied to
a placeholder email you have to edit before running, and it raises an
error if that email doesn't have an auth.users row yet — deliberately
not part of the automated pipeline. Run it manually, once, per step 3
below.
Copy .env.local.example to .env.local and fill in the two values from
step 1:
cp .env.local.example .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-public-key
NEXT_PUBLIC_SITE_URL=http://localhost:3000
When you deploy to Vercel, add the same variables in Project Settings →
Environment Variables, setting NEXT_PUBLIC_SITE_URL to your production
URL.
Open the Supabase SQL editor for your project and run, in order:
supabase/schema.sql — tables, enums, triggers, and Row Level Security
policies for every table (profiles, communities,
community_memberships, spaces, posts, comments, events,
resources).auth.users row and, via trigger, your profiles row.supabase/seed.sql, replace the placeholder email
([email protected]) with the address you just signed up with, and run
it. This creates the three starter communities (Kushukuru, Zanzibar,
Farming) with you as owner, plus example spaces, a welcome post, an
event, and a resource in each.Both SQL files are safe to re-run.
Run supabase/storage.sql in the SQL editor too. It creates two public
buckets and locks writes down by path, reusing the same RLS approach as
the rest of the schema:
avatars — one image per user at <user_id>/avatar.<ext>. A user can
only write to their own folder (checked via auth.uid()).community-assets — logos and covers at <community_id>/logo.<ext> and
<community_id>/cover.<ext>. Only that community's owner/admin can
write (checked via the same is_community_admin() helper used
elsewhere).Both buckets are public for reads, so <img> tags load straight from the
Storage CDN URL with no signed-URL step. Uploads happen directly from the
browser to Supabase Storage (see src/components/ui/image-upload.tsx),
not through a Vercel serverless function, so there's no request-body size
limit to worry about beyond the bucket's own file_size_limit (8MB).
Profile avatars are managed at /settings; community logo/cover images
are managed from a community's /admin page (owners/admins only).
Run supabase/invites.sql too. An owner/admin generates a shareable
link from a community's /admin page (optionally capping it to N uses
and/or an expiry, and choosing whether it grants member or
moderator), and copies the link out to share however they like (DM,
group chat, email themselves, etc) — no email-sending required for this
path.
Anyone who opens /invite/<code>:
next param (including through email confirmation) so they land back
on the invite and get redeemed automatically once authenticated.The community_invites table itself is only readable by that
community's admins via RLS — the preview and redemption logic instead
run through two SECURITY DEFINER functions
(get_invite_preview/redeem_invite) so a not-yet-a-member visitor can
validate and redeem a code without needing broader table access.
Run supabase/email-invites.sql too (adds an email column to
community_invites). This is a second, optional invite path — from the
same /admin page, an owner/admin can type an email address and Relate
sends an actual invite email, pre-scoped to one use.
With RESEND_API_KEY set (see .env.local.example), the email goes out
through Resend as a community-branded message — sender display name
is the community ("Mzungu Zanzibar <[email protected]>"), the
body carries the community's logo and name, and no auth account is
pre-created, so the invitee's "Create account" flow just works. Requires
the sending domain verified in Resend. Without the key, invites fall
back to Supabase Auth's admin API (auth.admin.inviteUserByEmail),
which uses the global "from Relate" template and pre-creates a
passwordless account (see the note at the end of this section).
This path needs a secret the other invite features don't:
service_role key (not the anon key).SUPABASE_SERVICE_ROLE_KEY — locally in .env.local, and
in Vercel's Project → Environment Variables for production. Never
prefix it with NEXT_PUBLIC_ and never commit a real value — it
bypasses Row Level Security entirely. It's only ever read server-side,
from src/lib/supabase/admin.ts, and only after the caller has
already been authorized by a normal RLS-protected insert (see
sendEmailInvite in src/app/c/[communitySlug]/members/invites-actions.ts).If the target email already has an account, inviteUserByEmail fails
(Supabase won't re-invite an existing user) — the invite link is still
created either way, so you can copy it from the list and share it
directly as a fallback.
Account emails — signup confirmations and password resets — go out through Supabase Auth, which relays via whatever SMTP provider you configure under Authentication → Emails → SMTP Settings. Supabase's built-in shared service is dev-only and rate-limited to a few messages an hour, so production needs your own provider. These settings are for Resend; other providers follow the same shape.
One big exception — signup confirmations. Whenever RESEND_API_KEY and
SUPABASE_SERVICE_ROLE_KEY are both set, Relate mints the confirmation link
itself with generateLink and sends its own email through Resend, for
every signup. A signup that carries community context (an invite link, a
/c/<slug> page, a community's own custom domain) is branded with that
community's name and logo — "Confirm your email to join Mzungu Zanzibar";
a bare platform signup gets a plain "Relate" version of the same message.
Only an unconfigured Resend or service-role key falls back to Supabase
Auth's default template described below.
That is a correctness fix, not just branding. Supabase's default template
links at GoTrue's /verify endpoint, which spends the one-time token on a
plain GET. Mail scanners and inbox prefetchers (Outlook Safe Links,
Proofpoint, Gmail, antivirus) open every link in an incoming message, so the
token is routinely burned before the member ever clicks — they then see
"that confirmation link is invalid or expired" on a link minted seconds
earlier. The /auth/confirm interstitial cannot prevent that, because the
spending happens on Supabase's host, not ours. What survives the round trip
is instead an ?error=access_denied&error_code=otp_expired bounce, or a PKCE
?code= that only exchanges in the very browser the signup form was
submitted from (so opening the email on a phone fails too). An app-minted
token_hash link has neither problem: nothing but the POST behind our own
"Confirm my email" button can spend it, and it carries no per-browser state.
Getting unstuck — /signup/resend. Anyone whose link was eaten or never
arrived can request a fresh one there (linked from the sign-in error, the
"check your inbox" screen, and any bounced confirmation link). It is
email-only: the address owner is the one who receives the link, so nothing
else needs proving, and it always reports the same success so the page can't
be used to probe which addresses have accounts. Behind it,
auth_user_confirmation_state() (a service-role-only lookup on
auth.users.email_confirmed_at) decides whether an address is a finished
account, an unconfirmed one, or unknown. That same lookup is what lets
signing up a second time actually work: a repeat signup on an
unconfirmed address now re-sends a fresh link instead of dead-ending on
"you already have an account", which is the other half of the same report.
| Field | Value |
|---|---|
| Host | smtp.resend.com |
| Port | 465 (SSL) — or 587 if 465 is blocked |
| Username | the literal word resend (not your email) |
| Password | a Resend API key (re_…), created under Resend → API Keys |
| Sender email | an address on a domain you've verified in Resend (e.g. [email protected]) |
Two gotchas cause almost every failure, and both surface as an opaque
500: Error sending confirmation email in Supabase → Logs → Auth
Logs (the app just shows a generic "please try again" message, since
Supabase returns no useful detail to the client):
535 "Authentication credentials invalid" — the SMTP username or
password is wrong. The username must be exactly resend, and the
password must be a current, correctly-pasted API key (no stray spaces,
not a revoked key).When email is misconfigured the account/invite is often still created — it's only the email that fails — so an admin can copy an invite link from the members list and share it directly as a stopgap.
Run supabase/notifications.sql too. Delivery is in-app only (a bell
icon with an unread count, refreshed on page load/navigation — not real
time, no email). The notifications table has no INSERT policy at
all, so nothing client-side can write into someone else's list — rows
are only ever created by SECURITY DEFINER trigger functions, fired on:
Open the bell (/notifications) and everything currently unread is
marked read as you view it.
Run supabase/event-rsvps.sql too. Attending is just row presence in
event_rsvps — RSVPing inserts a row, canceling deletes it (no separate
going/interested/declined states). Any active member can RSVP to an
event in their community; the events page shows an attendee avatar
stack + count per event, and a toggle button on upcoming events (past
events show who attended but drop the button).
Run supabase/space-types.sql too. Adds a space_type column to
spaces (Discussion, Journal, Gallery, Resources, Directory,
Challenges, Growth Journey, Q&A, Custom), chosen when a space is
created and editable afterward from Admin. Every type still renders
the plain discussion feed today except Resources and Journal
(below) — the rest are real, admin-choosable categories with their
own icon, and get dedicated behavior (member Directory search, a
Growth Journey timeline, Challenges) in follow-up rounds.
The Admin page's Spaces section is now a real builder: rename, change type/visibility, duplicate, delete, drag to reorder, or hide from navigation.
Migrations *_books_media_space_type.sql, *_books_media_space_type_check.sql
and *_space_media_items.sql. A books_media-type space is a family's
reviewed shelf of books, videos, shows, podcasts, games and apps — the
Homeschool and Co-op school kinds seed one as "Books & Media". Any
active member pastes a link; Review with AI (needs
ANTHROPIC_API_KEY) reads the page and searches for ratings, reviews and
parent guides, then fills in the title, who made it, a description, a
suggested age range, how much bad language there is and anything a
parent should know first (violence, scary scenes, romance, substances,
mature themes…). The parent checks every field, picks a verdict — good
for kids, good with a note, or not suitable with the reason why — and
adds it. The not-suitable shelf is its own tab so the reason is as easy
to find as the recommendation.
Reviews are per link per space (space_media_items.link_key), readable
by anyone who can see the space, and editable by whoever added them or
community staff. Each AI review is charged to the community's AI
allowance and capped per person per day (MEDIA_REVIEWS_PER_DAY).
Every community's shelf starts empty. Items are shared with other
communities by default (per item, share_with_other_communities), and
Bring in from other communities lists what other shelves have shared
— read through the SECURITY DEFINER function
shared_media_items_from_other_communities(), which returns only shared
rows and names the source community unless it is invite-only. "Add to
our shelf" copies one onto this community's shelf as the member's own
row (imported_from records where it came from).
Run supabase/space-journal.sql too, after space-types.sql and
community-custom-fields.sql (it reuses that migration's
profile_field_type enum). A journal-type space no longer shows the
posts feed: from Admin (expand a Journal space in the Spaces list),
owners/admins define the fields every entry should ask for — same
field types as custom profile fields (text, long text, number, date,
dropdown, multi-select, checkbox, URL), reorderable and deletable.
Members then log entries against those fields on the space's own
page; each entry stores its answers as jsonb keyed by field id
(space_journal_entries.data) so renaming a field doesn't orphan
past entries. Entries are visible to anyone who can see the space
(same can_view_space rule as posts); editing/deleting an entry is
limited to its author or community staff.
Run supabase/growth-journey.sql too, after space-journal.sql and
contribution-triggers.sql. Unlike the other space types, this one
needed no new table: a growth_journey-type space renders the
viewing member's own activity in that community — posts, journal
entries, and events they've RSVPed to — fetched from those existing
tables in parallel and merged chronologically
(src/lib/data/growth-journey.ts), grouped by month/year, with an
on-demand Annual Recap (counts per activity type, longest daily
streak, most active month) computed from the same data on request,
nothing stored. The one schema change is additive: a trigger that
awards member_contribution_scores points for logging a journal
entry, closing a gap left before Journal spaces existed — the four
other content types (posts, comments, resources, events) already had
this from contribution-triggers.sql.
No SQL to run — a directory-type space reuses the existing member
directory data layer and components as-is (getDirectoryMembers,
DiscoverySection, MemberDirectoryList), just rendered inside a
space instead of only at /c/[slug]/members. Gives admins a way to
put member search/discovery in their space navigation.
Run supabase/challenges.sql after space-types.sql. A
challenges-type space hosts one or more time-boxed programs
(space_challenges — named that instead of challenges to avoid
colliding with an unrelated pre-existing table of that name) that
members can join (space_challenge_participants, a plain join table
— no daily check-ins/progress tracking yet). Community admins create
challenges from the space; any member can join/leave. Status
(Upcoming / In progress / Ended) is derived from start_date/
end_date, nothing stored.
Run supabase/community-nav-links.sql. Lets a community's
owner/admins add arbitrary external links (e.g. a link to another
app) to that community's sidebar from Admin → Sidebar links — each
opens in a new tab. Visible to anyone who can already view the
community (members, or anyone if the community is public), managed
only by owner/admin (community_nav_links, RLS-gated the same way as
the rest of the admin-managed tables).
Run supabase/platform-admin.sql. There's no global "Resources" nav item
anymore — resources are added scoped to a specific space (a resources-type
space), not community-wide. What remains togglable — Events and the
Concierge search — is now a feature, off/on per community, decided by a
platform-wide super admin rather than a per-community setting: community
owners/admins can't turn these on or off themselves.
A super admin signs in and visits /admin (linked from the dashboard once
they have the flag) to:
feature_defaults)community_features) — resolved as override-if-present, else the platform default, else enabledThere's no UI to grant the first super admin — do it once by hand in the SQL editor after signing up:
update public.profiles set is_super_admin = true where username = 'you';
The setup wizard's "Place-Based Community" template creates spaces with
types (Explore Map, Marketplace, Business Directory, Guides, Clubs &
Groups, Volunteer Hub, Jobs Board, Accommodation, Local
Recommendations) that don't exist until you run these — skipping them
is why community setup can fail with new row for relation "spaces" violates check constraint "spaces_space_type_check" the moment that
template is picked. Run, in this order, after supabase/schema.sql
and supabase/space-types.sql:
supabase/place-community.sql — adds location_type/location_name
to communities, the nine space types above, and optional
lat/lng/location_label on posts and events (the start of a
"Living Map" — any post or event can optionally carry a place).supabase/business-directory.sql — depends on place-community.sql
(business_directory space type). Local businesses — restaurants,
cafes, shops, accommodation, services, and a local-trades catch-all.
Then run supabase/business-custom-categories.sql — lets staff add
their own categories per directory ("Fundi", "Boda Boda", …) beyond
the built-ins, and converts the category columns from enum to text.supabase/marketplace.sql — depends on place-community.sql
(marketplace space type). Goods, services, property, vehicles,
jobs, free items, wanted posts, experiences and tickets.supabase/jobs-board.sql — depends on place-community.sql (jobs
space type) and business-directory.sql (a posting can optionally
link to a business listing).supabase/accommodation.sql — depends on place-community.sql
(accommodation space type) and business-directory.sql (a listing
can optionally link to a business).supabase/explore-map.sql — depends on place-community.sql (map
space type). Togglable map layers (Restaurants, Beaches, Parks, …)
the wizard seeds from a place community's chosen location type.The setup wizard's Activity template is for a community built around
one thing people do together — hiking, running, cycling, climbing,
padel, surfing. Picking it asks which activity (ACTIVITY_KINDS in
src/lib/community-templates.ts, stored as communities.activity_kind)
and layers that activity's extras onto a shared starter set: Happening
Now, Discussion, Routes & Spots, Meet-Up Map, Crews, Find a Partner,
Trip Reports, Skills & Safety, Gear Exchange and Challenges.
The type it introduces is Meetups (space_type = 'meetups'), the
Happening Now board: a member posts what they're doing, when, where
they're meeting, the pace and how many spots — everyone else in the
community is notified, and whoever's free taps "I'm in". It is
deliberately not the Events calendar: events are scheduled ahead and
live in the sidebar, a meetup is interesting for the next few hours.
Nothing to run by hand — the migrations under supabase/migrations/
(*_activity_meetup_enum_values.sql onwards) add the meetups and
meetup_participants tables with their RLS, the meetup /
meetup_join notification types and triggers, the meetup feed item
type, realtime streaming for the board, and
communities.activity_kind.
This has been built in stages — Stages 1 through 7 are all in
place (schema/RLS, settings UI, community admin custom fields, the
enhanced member profile page, the Member Directory itself, 1:1
messaging, and contribution-score triggers). Run these six files, in
this order, after supabase/schema.sql:
supabase/member-profile-extensions.sql — adds professional fields
(profession, company, website, social links) and privacy switches
(hide_profile, hide_online_status, hide_communities,
hide_social_links, hide_business_profile, is_discoverable) to
profiles; adds business_profiles (one optional business profile per
user), member_interests, member_skills, member_help_requests
("needs help with" / "available to help with", distinguished by
kind), and member_locations (opt-in, approximate only — city/
region/country text, never a precise address or geocoding).supabase/community-custom-fields.sql — lets a community owner/admin
define unlimited custom profile fields scoped to their community
(community_profile_fields: text/textarea/number/date/dropdown/
multiselect/checkbox/url) and stores each member's answers
(community_profile_values).supabase/member-contribution.sql — member_contribution_scores is
an append-only points ledger; a trigger keeps a fast-sortable running
total in profiles.contribution_score. Nothing awards points yet —
that's wired up once the directory that displays the score exists.supabase/direct-messages.sql — simple 1:1 conversations +
direct_messages (no group chat), plus member_blocks (also used to
stop blocked members from messaging each other).supabase/member-connections.sql — depends on direct-messages.sql
(uses its is_blocked_between() helper). LinkedIn-style connection
requests; schema + RLS only, no UI yet — explicitly future-ready per
the spec this was built against.supabase/contribution-triggers.sql — depends on
member-contribution.sql. Awards real points for creating a post,
leaving a comment, sharing a resource, hosting an event, and
RSVPing to one — see the Stage 7 section below for details.A key privacy design decision worth knowing: hide_profile doesn't
block the existing profiles_select_authenticated policy outright
(that would break showing post/comment authors and member lists across
the app) — instead, a shares_active_community() helper lets anyone who
already shares an active community membership with you still see your
profile, while non-community strangers can't, once hide_profile is
set.
Stage 2 adds a settings UI for everything Stage 1's schema introduced:
/settings — professional info (profession, company, website, social
links), tag editors for interests/skills/needs-help-with/can-help-with
(backed by member_interests/member_skills/member_help_requests),
an approximate-location opt-in form (member_locations), and privacy
toggles for every hide_*/is_discoverable column on profiles./settings/business — create, edit, or remove your one optional
business_profiles row: logo (reuses the avatars storage bucket),
description, website, industry, location, comma-separated services/
products, and contact/social links./settings/blocked — lists everyone you've blocked with an unblock
action. Blocking itself is available to any member from a community's
members page (not just admins) via a new "block" icon next to each
other member, calling straight into member_blocks.Stage 3 adds the community admin UI for custom profile fields, on
the community's /admin page under "Custom profile fields":
is_community_admin) is the only
enforcement — the server actions don't duplicate the admin check.Stage 4 adds the enhanced member profile page, at
/c/[communitySlug]/members/[username] (linked from each row on the
members list):
is_visible), bio, contribution score (just the
number — never a "level"), online status, website, and social links.saveProfileFieldValues
action that re-fetches the field definitions server-side rather than
trusting the client); otherwise a read-only view that skips any
field the member hasn't answered.hide_* privacy toggle from Stage 2 gates its corresponding
section for other viewers (never for your own profile): social
links, online status, business profile, and communities. The
underlying profiles row visibility itself is still governed by
hide_profile + shares_active_community() from Stage 1 — a
profile that isn't visible to you at all 404s before any of this
renders.Stage 5 turns the community's /members page into the Member
Directory itself:
contribution_score — still just a number, never a
"level"), and Businesses.is_discoverable (Stage 2) governs the discovery sections only —
a member who opts out disappears from all six of them. It does
not remove them from the plain searchable roster below: that list
doubles as the admin member-management view (role changes, removal,
blocking), and hiding a member from it would break moderation, not
just discovery. This is a deliberate scope decision, not an
oversight.src/lib/data/member-directory.ts: getDirectoryMembers batch-
fetches each member's interests/skills/help-topics/location/business
profile alongside their membership row (a handful of .in(...)
queries, not N+1), plus the pure ranking/filtering functions behind
each section and the search match.Stage 6 adds simple 1:1 direct messaging on top of Stage 1's
conversations/direct_messages/member_blocks schema:
/messages — conversation list, most recent first, with an unread
count badge per conversation./messages/[conversationId] — the thread: messages bubble by
sender, a composer at the bottom, and unread messages from the other
participant are marked read server-side on render (same
mark-as-read-on-view pattern as /notifications).startConversation action that finds or creates the
canonical conversations row for the two participants
(user_one_id < user_two_id, enforced by both the DB check
constraint and the action). If the two members have blocked each
other, RLS rejects the insert and the button surfaces that as an
error instead of a silent failure.router.refresh(),
the same pattern used everywhere else in this app; a websocket/
Realtime-subscription upgrade is a possible future enhancement, not
part of this stage.MessagesNavLink/MessagesIconLink pair (mirroring the existing
notification bell) shows an unread-DM badge in both the dashboard and
community sidebars.Stage 7 wires real point-earning events into the contribution score
ledger built in Stage 1c. Run supabase/contribution-triggers.sql
after member-contribution.sql — it adds SECURITY DEFINER triggers
so that creating a post (+5), leaving a comment (+2), sharing a
resource (+5), hosting an event (+5), or RSVPing to one (+2) each
insert a row into member_contribution_scores, which the existing
Stage 1c trigger folds into profiles.contribution_score. No app code
changes needed — every page that already reads contribution_score
(the member profile page, Top Contributors) picks these up
automatically. Deliberately append-only: deleting the underlying post/
comment/resource/event/RSVP does not claw back points, consistent with
the ledger's own append-only design. Reactions and accepted-answers
are still future scoring sources per the original spec — this app has
no reactions/likes feature yet to hang a trigger off of.
New Supabase projects require email confirmation by default. In Authentication → URL Configuration, set:
http://localhost:3000 in
development, your Vercel URL in production)<site-url>/auth/confirm (and your production
equivalent)The confirmation email links to /auth/confirm, which shows a one-tap
"Confirm my email" button and exchanges the token for a session on the
POST behind it — never on page load, so a link scanner's automated GET
can't spend the token before the member arrives.
Note this section only governs the fallback path. With Resend and a service-role key configured, Relate mints and sends confirmation emails itself (see "Sending real email" above), and these templates are unused.
npm install
npm run dev
Visit http://localhost:3000, sign up, then follow step 3 above to seed
the starter communities.
Every piece of community content (spaces, posts, comments, events,
resources) is scoped to a community_id (directly or via a join), and
every table has Row Level Security enabled. Access is enforced in Postgres
— not just in the UI — via a handful of SECURITY DEFINER helper
functions (is_community_member, is_community_admin,
is_community_staff, can_view_space, …) used inside the RLS policies.
This means:
owner automatically
(see the on_community_created trigger) — try it at /communities/new.role (owner / admin / moderator / member) and
status (active / invited / banned) gate what a user can do
inside a given community.visibility (public / members / private) controls who
can see its posts and resources.This is the foundation for eventually letting other people create and sell their own communities on the same platform, without any schema changes.
Every community is automatically reachable at
<slug>.<platform-domain> — e.g. mzunguzanzibar.relate.click — with
the same clean URLs as custom domains (/ is the feed,
/spaces/explore-map is the map). The slug is read straight from the
hostname in src/proxy.ts (platformSubdomainSlug), so there is no
per-community setup, no database lookup, and no verification. Reserved
labels (www, api, admin, … — see RESERVED_SUBDOMAIN_LABELS in
src/lib/custom-domain.ts) never resolve to a community, and community
creation refuses them as slugs.
One-time ops setup: point the platform domain's nameservers at Vercel
DNS (ns1.vercel-dns.com / ns2.vercel-dns.com — wildcard domains on
Vercel require this), then add *.<platform-domain> in Vercel →
Settings → Domains. Every subdomain then gets routing and SSL
automatically. In dev, <slug>.localhost:3000 works out of the box.
Subdomains are each community's canonical address: any
/c/<slug>/… URL — opened on the apex, or while browsing on another
community's host — 308-redirects to <slug>.<platform-domain>/…. The
session survives the hop because auth cookies are scoped to
.<platform-domain> (sharedCookieDomain in
src/lib/custom-domain.ts), so one sign-in works on the apex and every
subdomain; custom domains keep their own host-scoped sign-in. The
canonical redirect is skipped in dev and on bare *.vercel.app
deployments, where wildcard subdomains don't resolve.
Welcome page. On a community's own host, a signed-out visitor
opening / gets the community's welcome page (src/app/welcome/) — cover,
description, public spaces, upcoming public events and join / log-in
buttons — rendered without the community shell. The proxy picks it when
the request carries no Supabase session cookie; /?view=feed (the page's
"Take a look around" link) shows the guest feed instead, and anyone signed
in gets the feed as before.
Run supabase/custom-domains.sql too, and make sure
SUPABASE_SERVICE_ROLE_KEY is set. A community owner can then serve
their community on a domain they own — mzunguzanzibar.com instead of
relate.example/c/mzungu-zanzibar — from the Custom domain card on
their /admin page (owner-only, not admins).
Owner flow. Enter the bare domain, then add two DNS records at the registrar:
_relate-verify.<domain> containing the token the
admin page displays — this proves control of the domain. "Check
verification" looks it up over DNS-over-HTTPS (Google, falling back
to Cloudflare) and marks the domain verified on a match.76.76.21.21), or a
CNAME to cname.vercel-dns.com for a www subdomain.www to cname.vercel-dns.com, so the www form works
too. src/proxy.ts serves the community on www.<domain> (or the
bare domain, when the owner verified the www form) as well, and
verification registers that counterpart on the Vercel project as a
redirect to the verified host. The proxy never redirects between the
two itself: if the Vercel project redirects the other way (its default
is apex to www), the two redirects would loop.Platform operator flow. With VERCEL_TOKEN + VERCEL_PROJECT_ID
set (see .env.local.example), there is nothing to do per domain:
"Check verification" also registers the domain on the Vercel project via
the Domains API (routing + SSL), and removing the domain deregisters it.
Without those env vars, the one manual step per domain is adding it in
Vercel → Settings → Domains.
The Supabase Auth redirect allowlist never needs per-domain entries:
confirmation emails always link to the platform's own origin
(NEXT_PUBLIC_SITE_URL). When a signup happened on a custom domain, the
link carries a return_host param and /auth/confirm forwards the
still-unverified token to that domain — after validating it against the
verified custom_domain column via the same security-definer lookup the
proxy uses, so tokens can only ever be forwarded to a verified community
domain on this app, never an arbitrary site. The custom domain's own
copy of the confirm route then verifies the token, which is what puts
the session cookie on the host the member actually uses (auth cookies
are host-scoped). Members who sign in or sign up on a custom domain
land on that community's feed (/) instead of /dashboard.
How routing works. src/proxy.ts inspects the Host header on
every request. Platform hosts (NEXT_PUBLIC_SITE_URL's host,
*.vercel.app, localhost) behave as before. Any other host is
resolved to a community slug via the community_slug_for_domain
security-definer function (only verified domains resolve; results are
cached in-memory), and the request is rewritten onto the
/c/[communitySlug] tree — / renders the community feed, /events
renders its events, and the browser URL stays on the custom domain.
Internal links still written as /c/<slug>/… get 308-redirected to the
bare path, and platform routes (/login, /dashboard, /settings, …)
keep their normal meaning on every host, so members sign in on the
domain they're visiting (Supabase auth cookies are host-scoped).
The three domain columns on communities can only be written through
the service-role client: a database trigger rejects direct API writes,
so the owner-checked server actions in
src/app/c/[communitySlug]/admin/actions.ts are the only path — an
admin can't mark a domain verified without the TXT record actually
existing. To test locally, map a hostname in /etc/hosts
(127.0.0.1 mzunguzanzibar.test), connect and verify it in the admin
UI (local verification needs a real public DNS record, so for a quick
test you can set custom_domain_verified_at by hand in the SQL
editor), then browse http://mzunguzanzibar.test:3000.
src/
app/
page.tsx Logged-out landing page
login/, signup/ Auth pages (Supabase email/password)
signup/resend/ Request a fresh activation link
auth/ Server actions + email confirmation route
dashboard/ Logged-in home: your communities + discovery
settings/ Profile settings: identity, professional info, social
links, interests/skills/help topics, location opt-in,
privacy toggles
settings/business/ Optional business profile (create/edit/remove)
settings/blocked/ Blocked members list + unblock
communities/new/ Create a community (you become its owner)
invite/[code]/ Invite link preview + auto-redemption
notifications/ In-app notifications list
messages/ Conversation list; messages/[conversationId]/ is the thread
c/[communitySlug]/ Everything scoped to one community
spaces/, spaces/[spaceSlug]/ Spaces + posts + comments
events/, admin/ Admin also handles branding, invites,
custom profile fields, and feature toggles
admin/ Platform admin (super admin only) — feature flags
members/ Member Directory: search/filter/sort + discovery
sections (new/recommended/near you/active/top/business)
members/[username]/ Enhanced member profile page
components/
ui/ Shared primitives (Button, Card, Avatar, ImageUpload, …)
layout/ Nav, sidebar links, mobile tab bar, logout, notification +
message unread bells
lib/
supabase/ Browser/server/proxy/admin (service-role) Supabase clients
data/ Typed data-access functions per domain
types/database.ts Hand-written types mirroring schema.sql
supabase/
migrations/ Timestamp-ordered SQL migrations, applied automatically on push to
main (see "Database migrations" above) — schema, storage, invites,
notifications, platform admin (feature flags), and every feature
table (events, business directory, marketplace, member directory,
messaging, journals, etc.)
seed.sql Starter communities, spaces, sample content — manual only, not
part of the automated migration pipeline
This project was generated on Next.js 16, which renamed middleware.ts to
proxy.ts (see src/proxy.ts) and made cookies(), headers(),
params, and searchParams asynchronous everywhere. Keep that in mind
when adding new dynamic routes or reading cookies in a Server Component.
Push to GitHub and import the repo in Vercel. Add the environment variables from step 2, then deploy — no other configuration is required. Supabase Storage (for avatar/logo uploads) is intentionally not wired up yet; it's a natural next milestone once the MVP is validated.