Nomads FC


A platform for reading what moves sport in a city before a brand spends money there.

Status

Live. Built May to June 2026.

RoleYearKindTry it
Concept, product, design, engineering2026Sport intelligencenomadsfc.com
28 daysone athlete, one market, one sprint
8roles in the portal, each seeing only its own view
14tables, every one behind row-level security
34access 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

One 28-day sprint per city. What it learns stays in the data model for the next one.

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.

LayerWhat it does
The requestAnything under /app without a session is sent to sign-in, and comes back to the page it asked for.
The pageEach page asks for a role (requireRole) or for access to one campaign (canAccessCampaign) before it loads anything.
The databaseRow-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.

RoleSees
admin, operator, commerce operatorEvery campaign, and the operator tools
athlete, agent, sponsor, partner, viewerOnly 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.

GroupTables
Places and peoplecity_profiles, athlete_market_profiles, brand_profiles, market_relationships
Pipelinespartner_pipeline, sponsor_pipeline
The sprintcampaign_ops, commerce_tests, activation_playbooks
What was learnedmarket_scorecards, sprint_learnings, audit_log
Accessuser_profiles, user_campaign_access

Every change an operator makes writes a line to audit_log with the record before and after.

In the code

Private repository

These are short excerpts from Beach-Bum/10x-site-sandbox, which is private.

lib/auth.ts
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.

supabase/fix-rls-recursion.sql
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

More

Project notes on the weblog
Open ↗
Nomads FC: Hyperlocal Scaling, the essay
Open ↗