Nomads FC
A platform for reading what moves sport in a city before a brand spends money there.
Live. Built May to June 2026.
| Role | Year | Kind | Try it |
|---|---|---|---|
| Concept, product, design, engineering | 2026 | Sport intelligence | nomadsfc.com |
| 28 days | one athlete, one market, one sprint |
| 8 | roles in the portal, each seeing only its own view |
| 14 | tables, every one behind row-level security |
| 34 | access rules written in the database itself |
The problem
Local sport does not move from a dashboard. It moves through athletes, clubs, gyms, shops, schools and fans: the people a city already trusts.
A brand entering a city has no way to read that before it spends.
The method
The platform maps those signals, then runs one short, measurable test per city: one athlete, one market, one 28-day sprint. Signal, then demand, then partners, then activation, then proof.
Limited product drops are how real demand is tested. If people buy, the signal is real.
What I built
- A public site. Editorial and video-led: the method, field notes such as the Birmingham field study, and pages for athletes, sponsors and partners. A product collection sits beside it.
- An invite-only portal. Sign-in is by one-time code, and nobody can sign themselves up. Each person has a role that decides what they can see.
- Proof rooms. Each campaign has a room only invited people can open. To anyone else the page behaves as though it does not exist.
- A data model. City profiles, athlete market profiles, partner and sponsor pipelines, commerce tests, scorecards and an audit log, so every sprint leaves intelligence for the next city.
How it is structured
Next.js 14 on the server, Supabase for Postgres and sign-in, IBM Carbon for the portal's components. 138 commits, May to June 2026.
How access is enforced
Sponsors, athletes and partners sign in to the same portal and must never see each other's campaigns. That is checked in three places, so a mistake in one does not open the door.
| Layer | What it does |
|---|---|
| The request | Anything under /app without a session is sent to sign-in, and comes back to the page it asked for. |
| The page | Each page asks for a role (requireRole) or for access to one campaign (canAccessCampaign) before it loads anything. |
| The database | Row-level security is switched on for all 14 tables. 34 policies say which role may read or change which rows, so a query that slips past the page still returns nothing. |
Sign-in is by one-time code and is invite-only: an email that is not already a user gets a plain "access is invite-only" message, and no account is created.
| Role | Sees |
|---|---|
| admin, operator, commerce operator | Every campaign, and the operator tools |
| athlete, agent, sponsor, partner, viewer | Only the campaigns they were granted, one proof room each |
A proof room is the page a sponsor sees. It is built from a fixed, reduced record (fan growth, content, commerce, partners, community) with no raw fan data and no internal notes in it. To someone without a grant the address answers "not found", the same as a campaign that does not exist.
The data model
Twelve tables hold what a sprint learns, in their own schema; two more hold people and their grants.
| Group | Tables |
|---|---|
| Places and people | city_profiles, athlete_market_profiles, brand_profiles, market_relationships |
| Pipelines | partner_pipeline, sponsor_pipeline |
| The sprint | campaign_ops, commerce_tests, activation_playbooks |
| What was learned | market_scorecards, sprint_learnings, audit_log |
| Access | user_profiles, user_campaign_access |
Every change an operator makes writes a line to audit_log with the record before and after.
In the code
These are short excerpts from Beach-Bum/10x-site-sandbox, which is private.
const INTERNAL_ROLES: UserRole[] = ["admin", "operator", "commerce_operator"];
/** Check if user can access a specific campaign. */
export async function canAccessCampaign(campaignCode: string): Promise<boolean> {
const profile = await getUserProfile();
if (!profile) return false;
// Internal roles can access all campaigns
if (isInternalRole(profile.role)) return true;
const user = await getUser();
if (!user) return false;
const supabase = createServerSupabase();
const { data } = await supabase
.from("user_campaign_access")
.select("campaign_code")
.eq("user_id", user.id)
.eq("campaign_code", campaignCode)
.single();
return !!data;
}What broke, and the fix
The first access rules could not be evaluated. A rule on the people table asked "is this person an admin?" by reading the people table, which ran the same rule again, without end. Postgres refuses to run a rule that calls itself, so any query that needed a role failed.
The fix was one small function that reads the caller's role with the rules switched off for that single lookup, and rules that call the function instead of the table.
CREATE OR REPLACE FUNCTION public.get_my_role()
RETURNS TEXT
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT role FROM public.user_profiles WHERE id = auth.uid()
$$;
CREATE POLICY "internal_read_all_profiles"
ON public.user_profiles FOR SELECT
USING (public.get_my_role() IN ('admin', 'operator'));How it is tested
By hand, from two written checklists that are run before every release: one for access (each role against each area, signed out and signed in) and one for the release as a whole. There are no automated tests yet. For a portal whose whole point is who can see what, the access checklist is the first thing I would automate.
Open questions
Payment checkout in the shop. The cart and product pages are built. Automated tests for the access rules.