Skip to content

White-Label Architecture

RL Padel Academy started as a single-club app for one academy. Its architecture — an Expo/React Native client with a native iOS 26 Liquid Glass UI, a PocketBase backend, and a Next.js admin — is already close to what a white-label product needs. This guide describes how to evolve the codebase from “one app for one club” into “one codebase that ships many branded club apps.”

Vision

The goal is one codebase → many branded club apps. Every padel club gets an app that looks and feels like their product (their name, colours, logo, enabled features) while sharing a single maintained code path. We support two delivery models:

ModelBackendBest forTrade-off
(a) Shared multi-tenantOne PocketBase with a club scope on every recordScale, low marginal cost, the long tail of small clubsStricter API rules, noisy-neighbour risk, shared maintenance window
(b) Per-club isolatedOne PocketBase instance per club (own Fly app + volume)Premium / enterprise clients with data-residency, compliance, or SLA needsHigher per-club cost and ops overhead

Recommendation: default to (a) shared multi-tenant for scale and economics; offer (b) isolated as a premium/enterprise tier. The application code is nearly identical in both cases — model (b) simply points the client at a different base URL and skips the club scoping because the whole instance belongs to one club.

Multi-Tenancy Data Design

Introduce a top-level clubs collection and a club relation on every existing collection (trainings, training_enrollments, events, event_enrollments, reservations, products, invoices, gallery_photos, news, trainer_availability). A club_memberships join table maps users to clubs with a role, so one person can belong to multiple clubs.

clubs collection

clubs
id text (auto)
name text required // "RL Padel Academy"
slug text required unique // "rl-padel" — used in club codes / deep links
branding json // colours, logo, fonts, appName (see Theming)
enabledFeatures json // feature-flag map (see Feature Flags)
contact json // email, phone, address
locale text default "nl-NL"
plan select starter|pro|enterprise
active bool default true

club_memberships join

club_memberships
user relation -> users required
club relation -> clubs required
role select lid|trainer|eigenaar required
// unique index on (user, club)

Scoping rule on every other collection

Add club relation -> clubs required to each domain collection. Then write PocketBase API rules that allow access only to records of clubs the requester belongs to. A reusable membership sub-query expresses this:

// List / View rule on e.g. reservations, trainings, events…
@request.auth.id != "" &&
club.id ?= @collection.club_memberships.club.id
? @collection.club_memberships.user.id ?= @request.auth.id
// Create rule — caller must be a member of the target club
@request.auth.id != "" &&
@collection.club_memberships.club.id ?= @request.body.club &&
@collection.club_memberships.user.id ?= @request.auth.id
// Owner-only write (e.g. products, news): additionally require role
@collection.club_memberships.role ?= "eigenaar"

For model (b) these rules collapse to the original single-club rules because the instance only ever holds one club’s data.

Per-Club Theming

The current design system reads every colour from a single token object in apps/frontend/components/native/theme.ts. Brand accents (teal: '#2e8b76', gold: '#c9a84c') are hard-coded there, while neutrals adapt to light/dark via a dyn(light, dark) helper backed by DynamicColorIOS. To white-label, we make the brand half of that object club-driven at runtime while keeping the adaptive neutrals exactly as they are.

Branding shape stored on the clubs record

{
"appName": "RL Padel Academy",
"primary": "#2e8b76",
"secondary": "#c9a84c",
"primaryLight": "#3aad94",
"primaryDark": "#1e5e4f",
"logo": "logo_rl.png",
"fontFamily": "System"
}

ThemeProvider + useClubTheme()

Wrap the app in a provider that merges the static token object with the club’s branding fetched from its clubs record, then expose it through a hook. Components import useClubTheme() instead of the static theme.

components/native/ThemeProvider.tsx
import { createContext, useContext, useMemo } from 'react';
import { theme as baseTheme } from './theme';
type ClubBranding = {
appName: string; primary: string; secondary: string;
primaryLight: string; primaryDark: string; logo: string;
};
const ClubThemeContext = createContext(baseTheme);
export function ThemeProvider({ branding, children }:
{ branding: ClubBranding; children: React.ReactNode }) {
const value = useMemo(() => ({
...baseTheme, // keeps all adaptive neutrals (bg, text, separators…)
teal: branding.primary, // override brand accents only
tealLight: branding.primaryLight,
tealDark: branding.primaryDark,
gold: branding.secondary,
}), [branding]);
return <ClubThemeContext.Provider value={value}>{children}</ClubThemeContext.Provider>;
}
export const useClubTheme = () => useContext(ClubThemeContext);

Light/dark still works untouched: only the fixed brand accents are overridden, and the adaptive neutrals (bg, bgElevated, text, separator, glass fills) remain DynamicColorIOS values that follow the system appearance. Glass materials (systemThinMaterial, etc.) are appearance-driven and need no change.

Logo, splash and app icon split into two cases:

  • Runtime (model: single app, many clubs): the logo image is a file on the clubs record and loaded as a remote/cached asset; the in-app header and splash component render it dynamically. The home-screen icon, however, is baked at build time and stays generic.
  • Per build (model: one App Store app per club): logo, splash, and app icon are bundled assets selected by app.config.ts (see Build Strategy), giving a fully native branded home-screen icon.

Feature Flags Per Club

enabledFeatures is a boolean map on the clubs record that gates whole screens and tabs:

{
"reservations": true,
"trainings": true,
"events": true,
"shop": true,
"gallery": true,
"billing": false,
"socials": true
}

Gate the native tab bar and the “Meer” (More) overflow rows from these flags:

const f = useClubTheme().features; // or a dedicated useFeatures() hook
<NativeTabs>
<NativeTabs.Trigger name="home" />
{f.reservations && <NativeTabs.Trigger name="reservations" />}
{f.trainings && <NativeTabs.Trigger name="trainings" />}
{f.events && <NativeTabs.Trigger name="events" />}
<NativeTabs.Trigger name="meer" />
</NativeTabs>
// Meer screen rows
{f.shop && <MeerRow icon="bag" title="Shop" href="/shop" />}
{f.gallery && <MeerRow icon="photo" title="Galerij" href="/gallery" />}
{f.billing && <MeerRow icon="euro" title="Facturen" href="/invoices" />}

Disabled features should also have their routes guarded server-side by the API rules above, so a flag flip is enforced, not just hidden.

Build / Release Strategy

ApproachHow a user lands in the right clubBranding depthProsCons
Single app, runtime selectionLogin by club code / deep link (rlpadel://club/<slug>) sets the active clubColours, logo, features at runtime; generic iconOne binary, instant onboarding, fastest to ship clubsNo per-club App Store presence; shared app icon/name
Per-club App Store appsApp is the club; bundled configFull native icon, name, splashPremium feel, club found by name in the store, push brandingOne review/release per club; EAS build matrix; more maintenance

Per-club apps use EAS build profiles with a dynamic app.config.ts driven by env:

app.config.ts
export default () => ({
name: process.env.CLUB_NAME ?? 'RL Padel',
slug: process.env.CLUB_SLUG ?? 'rl-padel',
ios: { bundleIdentifier: process.env.CLUB_BUNDLE_ID ?? 'com.rlpadel.app' },
icon: `./assets/clubs/${process.env.CLUB_SLUG}/icon.png`,
splash: { image: `./assets/clubs/${process.env.CLUB_SLUG}/splash.png` },
extra: { clubSlug: process.env.CLUB_SLUG, apiUrl: process.env.API_URL },
});
// eas.json — one profile per club (or a single matrix driven by env)
{ "build": {
"rl-padel": { "env": { "CLUB_SLUG": "rl-padel", "CLUB_NAME": "RL Padel Academy", "CLUB_BUNDLE_ID": "com.rlpadel.app" } },
"smashclub": { "env": { "CLUB_SLUG": "smashclub", "CLUB_NAME": "Smash Padel Club", "CLUB_BUNDLE_ID": "com.smashclub.app" } }
}}

How per-club App Store apps work mechanically

Apple identifies each app by its Bundle Identifier (e.g. com.rlpadel.app, com.smashclub.app). A different bundle ID = a separate App Store listing, separate icon, separate name in search — even though 100% of the React Native code is shared.

Each eas build run compiles the same source but bakes in different metadata for that club. Per-club assets (icon, splash) live next to the shared code:

apps/frontend/assets/clubs/
rl-padel/ icon.png splash.png
smashclub/ icon.png splash.png

Build → submit flow per club:

Terminal window
eas build --profile rl-padel --platform ios # → IPA: com.rlpadel.app
eas submit --profile rl-padel # → App Store: "RL Padel Academy"
eas build --profile smashclub --platform ios # → IPA: com.smashclub.app
eas submit --profile smashclub # → App Store: "Smash Padel Club"

In App Store Connect you create one new app record per club — all under the same Apple Developer account (one account, unlimited apps). Apple review is only required on first submission per app, not on subsequent updates.

For code updates, every release requires one build + submit per active club. With many clubs this is scriptable; EAS supports parallel builds. The code being reviewed is identical across apps — Apple is reviewing the bundle ID + metadata, not re-auditing the logic.

A pragmatic path: ship the single runtime app first for fast pilots, and graduate paying/enterprise clubs to their own branded build.

Domain Architecture

Three services need URLs: the PocketBase API, the admin panel, and Universal Links for the iOS app.

PocketBase API — centralized, you own it

Keep one PocketBase instance for the platform at a subdomain you control:

api.padelapp.com ← shared multi-tenant instance (model a)

Enterprise / isolated instances get their own subdomain:

api.smashclub.padelapp.com ← isolated instance (model b, provisioned by you)

The app’s API_URL env var in each EAS build profile points here. Clubs never need to know or configure this themselves.

Admin panel — centralized at admin.padelapp.com

Recommendation: one deployment at admin.padelapp.com, not per-club subdomains.

The admin is a B2B back-office tool, not a consumer product. Club owners (eigenaar) and staff log in with their credentials and the app scopes everything to their club automatically via club_memberships. Per-club admin domains (admin.smashclub.nl) add DNS + TLS provisioning overhead for every new client with no meaningful benefit.

admin.padelapp.com ← all clubs log in here; eigenaar sees only their club's data

Super-admin (you) gets an elevated platform role that sees all clubs and manages billing. A header showing the club logo and name after login gives eigenaar users the right context without needing a custom domain.

Universal Links let a URL open directly in the native app instead of a browser. Apple requires an apple-app-site-association (AASA) file served from the linked domain, and each app binary must declare that domain as an Associated Domain in its entitlements via app.config.ts.

Option A — platform subdomain (recommended for most clubs)

Use a wildcard subdomain you control:

rl-padel.padelapp.com
smashclub.padelapp.com

You serve one AASA file that covers all slugs. Each EAS build profile declares its associated domain:

app.config.ts
ios: {
bundleIdentifier: process.env.CLUB_BUNDLE_ID,
associatedDomains: [`applinks:${process.env.CLUB_SLUG}.padelapp.com`],
},

Deep link examples:

https://rl-padel.padelapp.com/join ← onboarding invite
https://rl-padel.padelapp.com/training/123 ← share a specific training

Option B — club’s own domain (premium tier)

A club with their own domain (e.g. smashclub.nl) can CNAME app.smashclub.nl → your infra. You detect the Host header server-side and serve the right AASA. The EAS build profile adds the custom domain as an additional associated domain:

associatedDomains: [
`applinks:smashclub.padelapp.com`, // fallback (always present)
`applinks:app.smashclub.nl`, // club's branded domain
],

This requires the club to set one CNAME record with their DNS provider — a one-time step covered in club onboarding.

Summary

ServiceURLDNS ownerNotes
PocketBase APIapi.padelapp.comYouCentralized; enterprise gets sub-subdomain
Admin paneladmin.padelapp.comYouAll clubs log in here; no per-club domain needed
App deep links (standard)<slug>.padelapp.comYouWildcard; one AASA file
App deep links (premium)app.<clubdomain>.comClub (CNAME → you)Optional; club handles DNS setup

App Store Risks with Multiple Apps from One Codebase

Shipping many apps from a single codebase is legitimate and common, but both Apple and Google have specific concerns. Being aware of these up front prevents rejections and account issues.

Apple App Store

Spam / clone policy (most important) Apple’s guideline 4.3 prohibits submitting “several apps that are nearly identical.” Apple has rejected and removed suites of white-label apps that differ only in name and colour. The key distinction Apple makes is user value: each app must offer a meaningfully different experience, not just a reskin.

Mitigations:

  • Each club app should have club-specific content (their own trainings, events, courts, photos) baked in or immediately available on launch, not a generic empty shell.
  • The App Store listing must clearly state the club name and what the app is for. Do not use generic descriptions across all submissions.
  • Avoid submitting many apps in a short window. Space submissions out and ensure each has real club data live before submission.
  • Consider using a single runtime app for smaller/newer clubs and only creating per-club App Store listings for clubs with an established member base. This is both pragmatic and reduces the surface area for policy concerns.

Account-level risk If Apple flags one submission as a clone, they may review your entire developer account. Keep each app’s metadata (description, keywords, screenshots) genuinely tailored to that club, not copy-pasted.

Push notification certificates Each bundle ID requires its own APNs (Apple Push Notification service) key or certificate. EAS manages this automatically per build profile, but you need to ensure the Apple Developer account has the APNs entitlement enabled for each app ID in the Certificates, IDs & Profiles portal.

Associated Domains / Universal Links per app Each bundle ID must separately declare its associated domains. If you later add a domain to one club’s app you must rebuild and resubmit that specific app — there is no cross-app configuration.

TestFlight per app Each bundle ID has its own TestFlight group. You cannot share a single TestFlight beta across club apps — useful to know when doing QA before club launches.

Google Play Store

Google’s policies are similar but somewhat more lenient in practice. The main risks:

Repetitive content policy Google Play’s repetitive content policy targets apps that replicate the same experience across multiple listings. Same mitigations apply: real club content, tailored store listings, no simultaneous mass-submission.

Developer account suspension Google has suspended developer accounts that submitted large batches of near-identical apps. Pace your submissions and differentiate each store listing.

Package name per app Like bundle IDs on iOS, each Android app needs a unique applicationId (e.g. com.rlpadel.app, com.smashclub.app). Once published, this cannot be changed — choose carefully and include it in eas.json alongside the iOS bundle ID.

Safest strategy

The risk is lowest when you follow this progression:

  1. Pilot phase — ship one binary (single runtime app, model a). Zero App Store risk, fast to onboard clubs.
  2. Growth phase — graduate 2–5 active, paying clubs to their own App Store listing. Ensure each has real content and a tailored store listing before submitting.
  3. Scale phase — automate EAS builds, but cap simultaneous new app submissions and space them out across weeks.

This graduated approach avoids the pattern Apple and Google flag (sudden burst of near-identical apps from one developer account) and gives you time to validate that each club’s app genuinely serves its members.

Admin

The existing Next.js admin becomes the per-club control panel. A club’s eigenaar signs in scoped to their club and manages:

  • Branding — primary/secondary colours, logo upload, app name (writes the branding json).
  • Feature toggles — checkboxes that write enabledFeatures.
  • Content — trainings, events, products, gallery, news for that club only.

Above this sits a super-admin tier (the platform operator) that can create clubs, set plans, provision isolated instances for enterprise clients, and view cross-club billing/usage. Super-admin is gated by a platform-level role outside club_memberships.

Onboarding a New Club Checklist

  1. Create the clubs record (name, slug, locale, plan).
  2. Set branding (colours, logo, app name) and enabledFeatures.
  3. Invite the eigenaar and create their club_memberships row.
  4. Import or invite members (lid) and trainers (trainer).
  5. Seed initial content: courts/reservation rules, training schedule, products.
  6. Choose delivery: hand over a club code / deep link (runtime app) or kick off an EAS build + App Store listing (per-club app).
  7. For enterprise: provision an isolated PocketBase instance and point the build’s API_URL at it.
  8. Verify theming, feature flags, and API-rule scoping in a smoke test before go-live.